@pikku/skills 0.12.8 → 0.12.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-addon/SKILL.md +58 -2
package/dist/skills.gen.js
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Maps each file under skills/ to its contents, so the skill set survives
|
|
5
5
|
// bundling into the `bun --compile` CLI binaries, where there is no filesystem
|
|
6
6
|
// copy to read. See scripts/embed.mjs for why.
|
|
7
|
-
export const SKILL_FILES = { "pikku-addon/references/addon-package-manifest.md": "# Addon Package Manifest Reference\n\n`npx pikku new addon` scaffolds these files. You rarely hand-edit them — consult this when wiring exports or config by hand.\n\n## Package Structure\n\n```text\nmy-addon/\n├── package.json # Exports .pikku/* and dist/\n├── pikku.config.json # addon: true + metadata\n├── tsconfig.json # #pikku path mapping\n├── src/\n│ ├── services.ts # createSingletonServices (required)\n│ └── functions/\n│ └── *.function.ts # Function definitions\n├── types/\n│ └── application-types.d.ts # SingletonServices interface\n└── .pikku/ # Generated (gitignored)\n```\n\n## pikku.config.json\n\n```json\n{\n \"tsconfig\": \"./tsconfig.json\",\n \"srcDirectories\": [\"src\", \"types\"],\n \"outDir\": \"./.pikku\",\n \"addon\": true,\n \"node\": {\n \"displayName\": \"My Addon\",\n \"description\": \"What this addon does\",\n \"categories\": [\"General\"]\n }\n}\n```\n\n## package.json (key fields)\n\n```json\n{\n \"name\": \"@my-org/addon-todos\",\n \"imports\": {\n \"#pikku\": \"./.pikku/pikku-types.gen.ts\",\n \"#pikku/*\": \"./.pikku/*\"\n },\n \"exports\": {\n \".\": { \"types\": \"./dist/src/index.d.ts\", \"import\": \"./dist/src/index.js\" },\n \"./.pikku/*\": \"./.pikku/*\",\n \"./.pikku/pikku-metadata.gen.json\": \"./.pikku/pikku-metadata.gen.json\",\n \"./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js\": {\n \"types\": \"./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.d.ts\"\n }\n },\n \"files\": [\"dist\", \".pikku\"],\n \"peerDependencies\": {\n \"@pikku/core\": \"*\"\n },\n \"scripts\": {\n \"pikku\": \"pikku all\",\n \"build\": \"tsc && cp -r .pikku dist/\"\n }\n}\n```\n", "pikku-addon/SKILL.md": "---\nname: pikku-addon\ndescription: >-\n Use when creating or consuming reusable function packages (addons) in Pikku. Covers wireAddon,\n ref(), pikkuAddonServices, pikkuAddonWireServices, addon package structure, and cross-project\n function sharing. TRIGGER when: code uses wireAddon/ref()/pikkuAddonServices, user asks about\n addons, reusable function packages, cross-project sharing, or addon package structure. DO NOT\n TRIGGER when: user asks about internal function composition (use pikku-rpc) or general function\n definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku Addons\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nAddons are reusable Pikku function packages that can be shared across projects. They bundle functions, services, secrets, and variables into a self-contained NPM package.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and addons\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireAddon(config)`\n\nRegister an addon in the consuming project:\n\n```typescript\nimport { wireAddon } from '#pikku'\n\nwireAddon({\n name: string, // Namespace for addon functions (e.g. 'todos')\n package: string, // NPM package name (e.g. '@pikku/addon-todos')\n rpcEndpoint?: string, // Optional remote RPC endpoint for distributed execution\n auth?: boolean, // Require a session for every function in the addon\n mcp?: boolean,\n tags?: string[], // Tags applied to all addon functions\n scopes?: string[], // Required of every function, on top of its own\n secretOverrides?: Record<string, string>, // Remap secret names\n variableOverrides?: Record<string, string>, // Remap variable names\n credentialOverrides?: Record<string, string>, // Remap credential names\n})\n```\n\n**`auth`, `tags` and `scopes` only ever tighten.** `auth: false` is not honoured —\nit would weaken the wiring's own gate — so the addon-level setting can require a\nsession but never waive one. The same package wired twice under two namespaces is\ngoverned by the union of both instances' scopes and tags.\n\n### `ref(name)`\n\nType-safe reference to a function — local or addon — for use in any wiring. It\nreturns a function config that proxies the call via RPC at runtime:\n\n```typescript\nimport { ref } from '#pikku'\n\nref('todos:addTodo') // namespace:functionName for an addon function\nref('myLocalFunc') // a local function by name\n```\n\nThere is no `addon()` helper; `ref()` covers both. For an addon that publishes\n**wiring contracts** rather than bare functions, codegen also emits `refHTTP`,\n`refChannel` and `refCLI`, which carry the addon's own route/config metadata:\n\n```typescript\nimport { refHTTP } from '#pikku'\n\nwireHTTP(refHTTP('todos:listTodos', { basePath: '/api' }))\n```\n\n### `pikkuAddonServices(factory)`\n\nDefine singleton services for an addon package (created once at startup). The\nsecond argument is always present — an addon never falls back to its own logger,\nvariables or secrets; the consuming app supplies them:\n\n```typescript\nimport { pikkuAddonServices } from '#pikku'\n\nexport const createSingletonServices = pikkuAddonServices(\n async (config, { secrets, logger }) => {\n const creds =\n await secrets.getSecret<GithubCredentials>('GITHUB_CREDENTIALS')\n return { github: new GithubService(creds.reveal()) }\n }\n)\n```\n\n`secrets` and `variables` arrive **typed against the addon's own declarations**,\nand a secret is a `SecretValue` — `.reveal()` is the only way to the plaintext\n(see `pikku-config`). `pikkuAddonConfig` is the matching factory for the addon's\nconfig object.\n\n### `pikkuAddonWireServices(factory)`\n\nDefine per-request services for an addon package (created fresh per HTTP request, queue job, etc.):\n\n```typescript\nimport { pikkuAddonWireServices } from '#pikku'\n\nexport const createWireServices = pikkuAddonWireServices(\n async (singletonServices, wire) => {\n // wire: transport context (http, channel, session, etc.)\n const authHeader = wire.http?.request?.header('authorization')\n return {\n myService: new MyService(authHeader),\n }\n }\n)\n```\n\n## Creating an Addon\n\n### Scaffold\n\n```bash\nnpx pikku new addon <name> # name is a required positional\nnpx pikku new addon stripe --display-name Stripe --category Payments --dir addons\n```\n\nThis generates `package.json` (exports `.pikku/*` + `dist/`), `pikku.config.json` (`addon: true`), `tsconfig.json` (`#pikku` path mapping), `src/services.ts`, `src/functions/`, and `types/application-types.d.ts`. For the full file contents/exports you rarely hand-edit, read `references/addon-package-manifest.md`.\n\n### Services\n\n```typescript\n// src/services.ts\nimport { pikkuAddonServices, pikkuAddonWireServices } from '#pikku'\nimport { TodoStore } from './todo-store.service.js'\n\nexport const createSingletonServices = pikkuAddonServices(async () => {\n const todoStore = new TodoStore()\n return { todoStore }\n})\n\n// Optional — only needed if addon functions require per-request services\nexport const createWireServices = pikkuAddonWireServices(\n async (singletonServices, wire) => {\n return {}\n }\n)\n```\n\n### Functions\n\n```typescript\n// src/functions/addTodo.function.ts\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku'\n\nconst AddTodoInput = z.object({ title: z.string() })\nconst AddTodoOutput = z.object({ id: z.string(), title: z.string() })\n\nexport const addTodo = pikkuSessionlessFunc({\n description: 'Adds a new todo',\n input: AddTodoInput,\n output: AddTodoOutput,\n func: async ({ todoStore }, { title }) => {\n return todoStore.add(title)\n },\n})\n```\n\nOptional approval gating (e.g. for agent tools) — add `approvalRequired: true` plus an `approvalDescription` resolver:\n\n```typescript\napprovalRequired: true,\napprovalDescription: async (_services, { title }) => `Add a todo called \"${title}\"`,\n```\n\n### Build\n\n```bash\nyarn pikku all # Generate types\nyarn tsc # Compile TypeScript\ncp -r .pikku types dist/ # Ship the generated files and the types they import\nyarn pikku validate # Check the published file set holds together\n```\n\n`yarn pikku`, not `npx pikku`: a scaffolded addon carries `@pikku/cli` as a\ndevDependency, and building it against a different CLI than it declares is how\ngenerated output ends up disagreeing with the packaged one. `npx pikku new\naddon` above is the exception — it runs before the addon, and its CLI, exist.\n\n`types/` has to be copied alongside `.pikku`: the generated files import\n`SingletonServices`, `Services`, `Config` and `UserSession` from\n`../../types/application-types.d.js`, and `tsc` never emits a hand-written\n`.d.ts` to `outDir`, so nothing else puts it in `dist`. Leave it out and the\naddon installs fine and fails to typecheck in every app that depends on it —\nwhich is what `pikku validate` is there to catch before you publish.\n\n## Consuming an Addon\n\n### Install & Register\n\n```bash\nyarn add @my-org/addon-todos\n```\n\n```typescript\n// wirings/todos.wirings.ts\nimport { wireAddon } from '#pikku'\n\nwireAddon({ name: 'todos', package: '@my-org/addon-todos' })\n```\n\nAfter registration, run `yarn pikku all` to generate types for the addon's functions.\n\n### Call via RPC\n\n```typescript\nexport const myFunc = pikkuFunc({\n func: async (_services, data, { rpc }) => {\n const todo = await rpc.invoke('todos:addTodo', { title: 'Buy milk' })\n return todo\n },\n})\n```\n\n### Wire to HTTP\n\n```typescript\nimport { wireHTTP, ref } from '#pikku'\n\nwireHTTP({\n method: 'get',\n route: '/todos',\n func: ref('todos:listTodos'),\n auth: false,\n})\n```\n\nOr batch multiple addon routes with `defineHTTPRoutes` + `wireHTTPRoutes`:\n\n```typescript\nimport { wireHTTPRoutes, defineHTTPRoutes, ref } from '#pikku'\n\nconst todoRoutes = defineHTTPRoutes({\n tags: ['todos'],\n auth: false,\n routes: {\n list: { method: 'get', route: '/todos', func: ref('todos:listTodos') },\n add: { method: 'post', route: '/todos', func: ref('todos:addTodo') },\n },\n})\n\nwireHTTPRoutes({ basePath: '/api', routes: { todos: todoRoutes } })\n```\n\n### Use in AI Agents\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku'\n\nexport const todoAgent = pikkuAIAgent({\n name: 'todo-agent',\n description: 'Manages a todo list',\n goal: 'You help users manage their todos.',\n model: 'openai/gpt-5-mini',\n tools: [\n ref('todos:listTodos'),\n ref('todos:addTodo'),\n ref('todos:deleteTodo'),\n ],\n maxSteps: 5,\n})\n```\n\nSee `pikku-ai-agent` — an addon function is just another `ref()` in `tools`.\n", "pikku-ai-agent/SKILL.md": "---\nname: pikku-ai-agent\ndescription: >-\n Use when building AI agents, chatbots, or LLM-powered assistants with Pikku. Covers\n pikkuAIAgent, ref() tool registration, memory, streaming, tool approval, thread ownership, and\n invocation via rpc.agent. TRIGGER when: code uses pikkuAIAgent/rpc.agent/runAIAgent/\n streamAIAgent, user asks about AI agents, chatbots, LLM assistants, tool-calling agents, agent\n memory/streaming, or `pikku enable agent`. DO NOT TRIGGER when: user asks about MCP tool\n exposure (use pikku-mcp) or general function definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku AI Agent Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nBuild AI agents that use Pikku functions as tools. Agents support conversation memory, streaming, and multi-step tool execution.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions that can be used as agent tools\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `pikkuAIAgent(config)`\n\nImport it from the generated agent types file — `#pikku` does not re-export it:\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/pikku-types.gen.js'\n\npikkuAIAgent({\n name: string, // Unique agent identifier\n description: string, // What the agent does (shown in agent listings)\n summary?: string,\n errors?: string[],\n\n // --- system prompt: three fields, joined role → personality → goal ---\n role?: string, // Who it is: 'You are a support engineer triaging bugs.'\n personality?: string, // How it sounds: tone, verbosity\n goal: string, // REQUIRED — what it is for\n\n model: string, // e.g. 'openai/gpt-5-mini'\n temperature?: number,\n providerOptions?: { // passed through untouched, keyed by provider\n openai?: { reasoningEffort?: 'minimal' | ... },\n },\n\n // --- capabilities: all three take ref() handles, not imported values ---\n tools?: unknown[], // ref('todos:addTodo'), ref('graph:sleep'), …\n agents?: unknown[], // sub-agents to delegate to\n workflows?: unknown[], // workflows callable as a tool\n agentMode?: 'delegate' | 'supervise',\n\n memory?: {\n storage?: string, // Service name for persistence (e.g. 'aiStorage')\n vector?: string, // Vector store service name\n embedder?: string, // Embedding service name\n lastMessages?: number, // How many messages to retain in context\n workingMemory?: ZodSchema, // Schema for structured working memory\n },\n\n maxSteps?: number, // Max tool-call rounds per invocation\n toolChoice?: 'auto' | 'required' | 'none',\n prepareStep?: (ctx) => void, // See \"Narrowing tools per step\"\n input?: ZodSchema,\n output?: ZodSchema, // Structured output — only honoured with NO tools\n tags?: string[],\n\n sessionScope?: 'user' | 'org', // Who owns this agent's threads. Default 'user'\n auth?: boolean, // Default false — see below\n scopes?: ScopeId[], // AND gate, checked before permissions\n permissions?: PermissionGroup,\n\n middleware?: PikkuMiddleware[],\n channelMiddleware?: PikkuChannelMiddleware[],\n aiMiddleware?: PikkuAIMiddlewareHooks[],\n})\n```\n\n**`goal` is the required prompt field, not `instructions`** — there is no\n`instructions` key. `role`/`personality`/`goal` are concatenated in that order,\nand nothing validates which text lands in which, so the split is purely for\nlegibility: prose in the \"wrong\" one still reaches the model.\n\n**Tools are `ref('domain:funcName')` handles, not imported function values.** The\ninspector resolves the ref against the generated function map, which is what lets\nan agent reach a function in another package (or a `graph:*` builtin) without an\nimport cycle.\n\n`auth` defaults to `false` because agents are usually invoked from an\nalready-authenticated `pikkuFunc`. `scopes` and `permissions` are enforced either\nway — see `pikku-permissions`.\n\n### Invoking an agent\n\nFrom inside a Pikku function, go through `wire.rpc.agent` — it carries the\nsession, credentials, and RPC depth for you:\n\n```typescript\nconst result = await rpc.agent.run('todo-agent', {\n message, threadId, resourceId, // required\n attachments?, model?, temperature?, context?,\n})\n\nawait rpc.agent.stream('todo-agent', input) // writes to the wire's channel\nawait rpc.agent.approve(runId, [{ toolCallId, approved }], expectedAgentName?)\nawait rpc.agent.resume(runId, { toolCallId, approved })\nawait rpc.agent.interrupt(runId, 'user' | 'speech' | 'timeout')\n```\n\n`context` is a string injected into the system prompt for this request only —\nuse it for upfront state (current org, project, deployment) so the agent stops\nasking the user for identifiers it could have been handed.\n\n`run` resolves to:\n\n```typescript\n{\n runId, threadId, text,\n object?, // set when the agent has an `output` schema\n steps, // tool calls made\n usage: { inputTokens, outputTokens },\n status?: 'completed' | 'suspended',\n pendingApprovals?: [{ toolCallId, toolName, args, reason?, runId }],\n}\n```\n\n`runAIAgent` / `streamAIAgent` from `@pikku/core/ai-agent` are the layer beneath\nthis. Their third argument is `RunAIAgentParams` (`{ sessionService?,\ngetCredential?, anonymousOwnerResourceId? }`) — **not** `{ singletonServices }`.\nReach for them only outside a wired function; inside one, `rpc.agent` is the\nsupported path.\n\n### Stream events\n\n`rpc.agent.stream` pushes `AIStreamEvent`s onto the channel:\n\n```typescript\n// { type: 'step-start', stepNumber }\n// { type: 'text-delta' | 'reasoning-delta', text }\n// { type: 'tool-call', toolCallId, toolName, args }\n// { type: 'tool-result', toolCallId, toolName, result }\n// { type: 'agent-call' | 'agent-result', agentName, session, input | result }\n// { type: 'approval-request', toolCallId, toolName, args, reason?, runId? }\n// { type: 'credential-request', toolCallId, toolName, credentialName,\n// credentialType: 'oauth2' | 'apikey', connectUrl?, runId }\n// { type: 'usage', tokens: { input, output }, model }\n// { type: 'transcript', text } // what the user was heard to say\n// { type: 'audio-delta', data, format, text? } | { type: 'audio-done' }\n// { type: 'data', name, data } | { type: 'generative-ui', spec }\n// { type: 'suspended', reason: 'rpc-missing', missingRpcs }\n// { type: 'interrupted', runId, text, reason }\n// { type: 'error', message }\n// { type: 'done' }\n```\n\nEvery event except `agent-call`/`agent-result`/`suspended` also carries optional\n`agent` and `session` fields, so a UI can attribute output to a sub-agent rather\nthan folding it into the parent's transcript.\n\n## Usage Patterns\n\n### Define an Agent\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/pikku-types.gen.js'\n\nexport const todoAgent = pikkuAIAgent({\n name: 'todo-agent',\n description: 'Manages a todo list',\n goal: 'You help users manage their todos. You can list, add, complete and delete them.',\n model: 'openai/gpt-5-mini',\n tools: [\n ref('todos:listTodos'),\n ref('todos:addTodo'),\n ref('todos:completeTodo'),\n ref('graph:sleep'),\n ],\n memory: { storage: 'aiStorage', lastMessages: 20 },\n maxSteps: 10,\n toolChoice: 'auto',\n})\n```\n\n### Scaffold the HTTP surface\n\n```bash\npikku enable agent # session required\npikku enable agent --noAuth # public\n```\n\nThe next `pikku all` generates `agent.gen.ts` — run/stream/approve/resume\ncallers plus thread listing endpoints, with thread ownership already enforced\nagainst the session. Don't hand-write these routes.\n\n### Structured output\n\nAn `output` schema fills `result.object`, but **only when the agent exposes no\ntools** — with a tool present the runner falls back to free text, silently. If\nyou need both, split the classification into its own tool-free agent.\n\n```typescript\nexport const structuredAgent = pikkuAIAgent({\n name: 'structured-agent',\n description: 'Classifies a message and returns a structured verdict',\n goal: 'You classify the sentiment of the user message.',\n model: 'openai/gpt-5-mini',\n output: z.object({ sentiment: z.string(), score: z.number() }),\n})\n```\n\n### Narrowing tools per step\n\n`prepareStep` runs before each step with the live tool array for that step, so\nmutating it in place changes what the model is offered from there on. `stop()`\nends the loop — called before step 0 the run completes with an empty result\nrather than signalling that it was short-circuited.\n\n```typescript\nprepareStep: ({ stepNumber, tools }) => {\n if (stepNumber >= 1) tools.length = 0 // withdraw tools after the first step\n}\n```\n\n### Tool approval\n\nA tool that should pause for a human sets `approvalRequired: true` (with an\noptional `approvalDescription`) on the *function*, not on the agent. The run then\nresolves with `status: 'suspended'` and `pendingApprovals`, and streaming emits\n`approval-request`. Answer with `rpc.agent.approve(runId, approvals)`.\n\nAuthorization around tools is two-layer: an agent only sees tools its session can\nreach, and the function's own `permissions` still guard the call when the model\npicks one.\n\n### Thread ownership\n\n`resourceId` is caller-supplied but never trusted as an owner. The session's\nprincipal (`userId`, or `orgId` when `sessionScope: 'org'`) is prefixed onto it,\nso a client can sub-partition inside its own boundary and cannot read across one.\nA sessionless run gets an ephemeral anonymous owner instead.\n\n## Complete Example\n\n```typescript\n// functions/todos.functions.ts\nexport const listTodos = pikkuSessionlessFunc({\n description: 'List all todo items',\n func: async ({ db }, { status }) => {\n return { todos: await db.listTodos(status) }\n },\n})\n\nexport const createTodo = pikkuFunc({\n description: 'Create a new todo item',\n func: async ({ db }, { text, priority, dueDate }) => {\n return await db.createTodo({ text, priority, dueDate })\n },\n})\n\nexport const completeTodo = pikkuFunc({\n description: 'Mark a todo as complete',\n func: async ({ db }, { todoId }) => {\n return await db.completeTodo(todoId)\n },\n})\n\n// agents/todo-assistant.agent.ts\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/pikku-types.gen.js'\n\nexport const todoAssistant = pikkuAIAgent({\n name: 'todo-assistant',\n description: 'A helpful assistant that manages todos',\n role: 'You are an assistant that manages a user’s todo list.',\n personality: 'Concise. One short paragraph unless asked for detail.',\n goal: `Keep the user's todos accurate.\n - When creating todos, infer priority if not specified\n - When listing todos, summarize the results`,\n model: 'openai/gpt-5-mini',\n tools: [\n ref('todos:listTodos'),\n ref('todos:createTodo'),\n ref('todos:completeTodo'),\n ],\n memory: { storage: 'aiStorage', lastMessages: 20 },\n maxSteps: 5,\n temperature: 0.7,\n})\n\n// Wire to HTTP for a chat endpoint — or skip this entirely and run\n// `pikku enable agent`, which scaffolds run/stream/approve/resume for you.\nwireHTTP({\n method: 'post',\n route: '/chat',\n func: pikkuFunc({\n title: 'Chat',\n func: async (_services, { message, threadId }, { session, rpc }) => {\n return await rpc.agent.run('todo-assistant', {\n message,\n threadId,\n resourceId: session.userId,\n })\n },\n }),\n})\n```\n", "pikku-ai-vercel/SKILL.md": "---\nname: pikku-ai-vercel\ndescription: >-\n Use when setting up AI agent execution with the Vercel AI SDK in a Pikku app. Covers\n VercelAIAgentRunner for streaming and non-streaming AI agent steps. TRIGGER when: code uses\n VercelAIAgentRunner, user asks about Vercel AI SDK integration, AI agent runners, or\n @pikku/ai-vercel. DO NOT TRIGGER when: user asks about AI agent wiring (use pikku-ai-agent) or\n voice I/O (use pikku-ai-voice).\ninstallGroups: [core]\n---\n\n# Pikku AI Vercel (Agent Runner)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/ai-vercel` provides an AI agent runner backed by the [Vercel AI SDK](https://sdk.vercel.ai/). Implements `AIAgentRunnerService` from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/ai-vercel ai @ai-sdk/openai # or any AI SDK provider\n```\n\n## API Reference\n\n### `VercelAIAgentRunner`\n\n```typescript\nimport { VercelAIAgentRunner } from '@pikku/ai-vercel'\n\nconst runner = new VercelAIAgentRunner(\n providers: Record<string, any>, // provider name → AI SDK provider\n providerFactory?: (apiKey: string) => Record<string, any>,\n allowedAttachmentHosts?: string[]\n)\n```\n\n**Methods:**\n\n- `stream(params: AIAgentRunnerParams, channel: AIStreamChannel): Promise<AIAgentStepResult>` — Stream AI responses with tool calls\n- `run(params: AIAgentRunnerParams): Promise<AIAgentStepResult>` — Execute a single AI step (non-streaming)\n- `transcribe({ model, audio, … })` / `generateSpeech({ model, text, voice, … })` — what `voiceInput`/`voiceOutput` call; see `pikku-ai-voice`\n- `generateImage`, `embed`, `embedMany`, `rerank` — the remaining AI SDK surfaces\n- `withApiKey(apiKey)` — returns a **new** runner built from `providerFactory`; returns `this` unchanged when no factory was supplied or the key is blank. This is the per-user-credential path\n\n### Model strings are `provider/model`\n\nSlash, not colon: `'openai/gpt-5-mini'`, `'deepinfra/hexgrad/Kokoro-82M'`,\n`'ollama/qwen2.5:7b'`. Only the **first** slash splits, so the model name may\ncontain its own. A string with no slash at all throws rather than defaulting to\na provider.\n\n### The `'*'` catch-all\n\n`providers['*']` resolves any provider name with no exact entry, and exact\nentries win — which makes \"everything through the gateway except this one\"\nexpressible as `{ deepinfra: direct, '*': gateway }`. Point it only at something\nthat genuinely accepts arbitrary model names (a gateway, or a scripted test\nprovider); aimed at a single vendor, an `anthropic/...` string silently reaching\nOpenAI is a bug, not a fallback.\n\n`providers` is public and mutable so deploy-time contributors can swap in\ngateway-routed providers after construction.\n\n## Usage Patterns\n\n### Basic Setup\n\n```typescript\nimport { VercelAIAgentRunner } from '@pikku/ai-vercel'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport { createAnthropic } from '@ai-sdk/anthropic'\n\nconst createSingletonServices = pikkuServices(async (config, { secrets }) => {\n const providers: Record<string, any> = {}\n if (await secrets.hasSecret('OPENAI_API_KEY')) {\n providers.openai = createOpenAI({\n apiKey: (await secrets.getSecret('OPENAI_API_KEY')).reveal(),\n })\n }\n return { config, aiAgentRunner: new VercelAIAgentRunner(providers) }\n})\n```\n\nThe service key is **`aiAgentRunner`** — that is the name the agent wiring looks\nup. Registering it as `aiRunner` leaves every agent unable to call a model.\n\n### With an agent\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\n\nexport const assistant = pikkuAIAgent({\n name: 'assistant',\n description: 'Answers questions',\n goal: 'You are a helpful assistant.',\n model: 'openai/gpt-5-mini',\n})\n```\n\nThere is no `wireAIAgent` — agents are declared with `pikkuAIAgent` from the\ngenerated agent types. See `pikku-ai-agent` for the full config.\n\n### Testing without a real provider\n\nReplacing the *provider* rather than the runner keeps every code path under test\nreal — tool loop, streaming, memory, approvals — and only scripts the replies.\nSealing it with `'*'` means no model string, including ones added later, can\nreach a live endpoint:\n\n```typescript\nnew VercelAIAgentRunner({ '*': createMockLlmProvider() })\n```\n", "pikku-ai-voice/SKILL.md": "---\nname: pikku-ai-voice\ndescription: >-\n Use when adding voice input (speech-to-text) or voice output (text-to-speech) to AI agents in a\n Pikku app. Covers the voiceInput/voiceOutput AI middleware from @pikku/core/ai-agent, per-script\n voices, and barge-in. TRIGGER when: code uses voiceInput, voiceOutput, or user asks about voice\n agents, speech-to-text, text-to-speech, transcription, or @pikku/ai-voice. DO NOT TRIGGER when:\n user asks about AI agent wiring generally (use pikku-ai-agent) or the runner itself (use\n pikku-ai-vercel).\n---\n\n# Pikku AI Voice (Speech I/O)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## `@pikku/ai-voice` is deprecated and empty\n\nThe package still publishes, but its entire source is `export {}` — there are no\n`STTService`/`TTSService` interfaces and nothing to import. Do not add it as a\ndependency.\n\nVoice now lives in **`@pikku/core/ai-agent`** as two AI middlewares, and the\nspeech models are reached through the `aiAgentRunner` (`transcribe` /\n`generateSpeech`) rather than through separate services. See `pikku-ai-vercel`.\n\n## API Reference\n\n```typescript\nimport { voiceInput, voiceOutput } from '@pikku/core/ai-agent'\n\nvoiceInput(config?: {\n model?: string // transcription model — required in practice\n language?: string // forwarded as openai providerOptions.language\n allowedAudioHosts?: string[] // allowlist for audio parts given as a URL\n})\n\nvoiceOutput(config?: {\n model?: string // speech model — required in practice\n voice?: string\n format?: string\n instructions?: string\n speed?: number\n language?: string\n speakableScripts?: string[] | Record<string, string>\n always?: boolean\n})\n```\n\nBoth attach through the agent's **`aiMiddleware`** array, not a\n`middlewareHooks` option, and the agent is declared with `pikkuAIAgent` — there\nis no `wireAIAgent`.\n\n### `voiceInput` — audio in, text in its place\n\nIt rewrites the last user message, replacing each `audio/*` file part with a\ntext part holding the transcript. Downstream nothing can tell the turn was\nspoken, which is why it records two shared-notes keys on the way past:\n\n- `SPOKEN_TURN` (`'voice:spokenTurn'`) — `true`/`false` on every turn it sees.\n **Absent** when the middleware isn't wired at all, which is what lets\n `voiceOutput` still speak for a caller that has no voice input.\n- `SPOKEN_TRANSCRIPT` (`'voice:transcript'`) — what the user was heard to say,\n only when something was heard. The stream wiring forwards it to the client as\n a `transcript` event; a voice client has no other way to know what its own\n audio said, and without it the user's turn renders as an empty bubble.\n\nBehaviours that decide how a voice loop should be written:\n\n- **It is a no-op without `aiAgentRunner.transcribe`** — no error, the audio\n simply passes through untouched.\n- **`config.model` is required once audio actually arrives**, and throws then\n rather than at wiring time.\n- **A turn that was entirely non-speech throws `NoSpeechDetectedError`.** Catch\n it and go back to listening without running the agent — answering a\n hallucinated sentence is worse than answering nothing. It is deliberately\n distinct from a transcription failure, which is worth reporting.\n- **Non-speech means an empty transcript, and nothing cleverer.** There was a\n per-segment confidence gate here and it was removed: Whisper is\n subtitle-trained, so it is *confident* when it invents (\"Thank you.\" scored\n better than the real sentence beside it). Pick an ASR that returns an empty\n string on silence rather than trying to filter one that doesn't.\n- Audio arrives either inline (base64 `data`) or as a `url` fetched through\n `safeFetch`; either way 50MB is the ceiling.\n\n### `voiceOutput` — sentence-at-a-time synthesis\n\nIt intercepts the output stream, buffers `text-delta`s to a sentence boundary,\nand synthesizes each finished sentence immediately, so the first is playing while\nthe rest is still being written. Emissions are chained even though generation\noverlaps, so the client hears them in order; on `done` it flushes the tail,\nawaits the chain, and emits `audio-done` before the `done` event.\n\n- **It speaks only in reply to speech** unless `always: true`. Only an explicit\n `SPOKEN_TURN === false` silences it — the key being absent (no `voiceInput`\n wired) still speaks. Set `always` for a read-aloud mode or a kiosk, where the\n whole output is meant to be heard; leave it off for an agent serving both typed\n and spoken callers, since synthesizing replies nobody is listening to costs\n real money per sentence.\n- **A failed sentence is logged and skipped**, not thrown — one silent sentence\n beats the rest of the reply never arriving.\n- **Barge-in aborts synthesis, not just playback**: the stream's `signal` is\n passed to the speech model, so sentences in flight stop being billed.\n\n### `speakableScripts` — declare what the model can pronounce\n\nHanded a script it has no voice for, a speech model typically neither fails nor\nstays quiet: Kokoro reads out the *letter names* — 24 seconds of \"Arabic meem,\nArabic ra\" for a one-line sentence. Declaring the range leaves anything outside\nit unspoken and reports it once per reply as a `voice-unsupported` data event.\n\nThe record form maps script → voice, because a multilingual model usually needs\nthe matching voice too: asked for Chinese in a default American-English voice,\nKokoro spells the characters out in 9.9s where `zf_xiaobei` says it in 3.5.\n\nKnown scripts: `latin`, `devanagari`, `han`, `kana`, `arabic`, `cyrillic`,\n`hangul`, `hebrew`, `greek`, `thai`. A sentence in several is settled by\nprecedence, not config order — `kana` first (it appears only in Japanese, so it\ndecides; `han` alone cannot), `latin` last (it turns up inside sentences in every\nother script). Omitting the option means no check at all, which is right for a\ngenuinely multilingual provider.\n\n`unspeakableScripts(text, speakable)` and `voiceForText(text, speakable,\nfallback)` are exported if you need the same decision outside the middleware.\n\n## Usage Pattern\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { voiceInput, voiceOutput } from '@pikku/core/ai-agent'\n\nexport const voiceAssistant = pikkuAIAgent({\n name: 'voice-assistant',\n description: 'Holds a spoken conversation',\n goal: 'You are a voice assistant. You are being listened to, not read.',\n model: 'openai/gpt-5-mini',\n aiMiddleware: [\n voiceInput({ model: 'deepinfra/openai/whisper-large-v3-turbo' }),\n voiceOutput({\n model: 'deepinfra/hexgrad/Kokoro-82M',\n speakableScripts: {\n han: 'zf_xiaobei',\n kana: 'jf_alpha',\n devanagari: 'hf_alpha',\n latin: 'af_bella',\n },\n }),\n ],\n})\n```\n\nWrite the goal for the ear: no lists, no markdown, no IDs read digit by digit.\nThe one thing worth spelling out is approvals — spoken aloud, the confirmation\nsentence is all the user gets, so let `approvalDescription` on the tool produce\nit and forbid the model from asking for permission in its own words.\n", "pikku-audit/SKILL.md": "---\nname: pikku-audit\ndescription: >-\n Use when adding audit / activity-history / change-tracking to a Pikku app, or when a function\n needs to record who changed what. Covers the built-in AuditService sink, the per-invocation\n auditLog buffer (createInvocationAudit / pikkuWireServices), the `audit: true` function flag,\n explicit `auditLog.write()` domain events, automatic query-level capture via\n createAuditedKysely, and the durable KyselyAuditService sink. TRIGGER when: user asks for an\n audit log, change history, activity feed, \"who did this\", or a custom audit/history table; code\n uses auditLog, createInvocationAudit, createAuditedKysely, or AuditService. DO NOT TRIGGER when:\n user wants app logging/telemetry (use the logger) or DB migrations in general (use\n pikku-kysely).\ninstallGroups: [core]\n---\n\n# Pikku Audit\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Check how services are wired (`services.ts`) and whether an `audit` table migration exists before adding audit calls.\n2. NEVER hand-roll a custom `audit_log` / history table with direct `insertInto('audit_log')` calls. The framework owns audit. A bespoke table drifts from the runtime (missing user/trace/wire context, hand-written CHECK constraints that reject valid events, no prod sink). Use the built-in path below.\n3. Make the smallest source change: mark the function `audit: true`, inject `auditLog`, call `auditLog.write(...)`. Do not invent a new service.\n4. Validate with `pikku all` (regenerates the service flags) then run the app / e2e.\n\n## Mental model — two layers\n\n- **`audit` (singleton `AuditService`)** — the durable **sink**. Write-only: `audit(event)` + optional `write(batch)`. Defaults to `NoopAuditService` (discards). Swap in a real sink to persist (see Sinks).\n- **`auditLog` (wire service `AuditLog`)** — a per-invocation **buffer** built from the sink via `createInvocationAudit(audit, wire)`. `auditLog.write(input)` enriches each event with `functionId`, `wireType`, `traceId`, `occurredAt`, and `userIdentity` (from the wire session) automatically, then flushes to the sink when the invocation ends.\n\nAn event only persists when the function opts in with **`audit: true`** — otherwise `auditLog` is a no-op that warns once per invocation, naming the function that dropped the write.\n\n`audit` also takes a config object, `{ durability: 'best-effort' | 'transactional' }`, and `audit: true` is shorthand for `'best-effort'`. Best-effort buffers events and flushes them when the invocation closes, swallowing sink failures with a warning — the function's result is never held hostage to the audit sink. `'transactional'` awaits the sink on every `write()` instead, so a sink failure fails the invocation. Reach for it only when losing the record is worse than failing the call.\n\n## Wiring (services.ts)\n\n```typescript\nimport { NoopAuditService, createInvocationAudit } from '@pikku/core/services'\n\nexport const createSingletonServices = pikkuServices(async (config, existing) => {\n // Prod platforms may inject a queue-backed sink as existing.audit.\n const audit = existing?.audit ?? new NoopAuditService()\n return { ...existing, config, /* ... */ audit }\n})\n\n// auditLog is created per invocation from the sink. Returned unconditionally so\n// a write from a function that forgot `audit: true` warns instead of vanishing.\nexport const createWireServices = pikkuWireServices(async (services, wire) => {\n if (!services.audit) return {}\n return {\n auditLog: createInvocationAudit(services.audit, wire, services.logger),\n }\n})\n```\n\nThe optional third argument is the fallback logger for the dropped-write warning\nand for best-effort flush failures. Without it those messages only surface when\nthe wire happens to carry a logger, which is how a missing `audit: true` goes\nunnoticed.\n\n`audit` and `auditLog` are already declared on `CoreSingletonServices` / `CoreServices`, so no type change is needed to inject them.\n\n## Recording events — explicit domain events (default)\n\nMark the function `audit: true` and call `auditLog?.write(...)`. Domain history goes in `metadata`; the user identity is derived from the session, so do NOT pass it manually.\n\n```typescript\nexport const cancelInvoice = pikkuFunc({\n audit: true, // REQUIRED — else write() is a no-op\n input: CancelInvoiceInput,\n output: CancelInvoiceOutput,\n func: async ({ kysely, auditLog }, { invoiceId }, { session }) => {\n const inv = await kysely.selectFrom('invoice')/* ... */.executeTakeFirstOrThrow()\n await kysely.updateTable('invoice').set({ status: 'cancelled' })/* ... */.execute()\n\n await auditLog?.write({\n type: 'invoice.update',\n source: 'explicit',\n metadata: {\n entity: 'invoice',\n entityId: invoiceId,\n action: 'update',\n field: 'status',\n before: inv.status,\n after: 'cancelled',\n },\n })\n return { ok: true }\n },\n})\n```\n\nFor a **system/cron** function there is no session, so `userIdentity` is simply absent (nulls out `user_id`). Use `pikkuVoidFunc({ audit: true, func: async ({ auditLog }) => { ... } })` — the void/config form accepts `audit`.\n\nHelper functions (in `lib/`) that record audit take `auditLog?: AuditLog` in their services arg and are passed it from a `audit: true` caller — never import a service.\n\nNote: events buffer and flush on invocation close. For a write inside a DB transaction, call `auditLog.write()` **after** the transaction commits — the sink is not part of your `trx`, so only record committed state.\n\n`write` is `Safe<>`-guarded the way the logger is: `input` and `metadata` are `unknown`, so a `SecretValue` nested anywhere in the event collapses the call to `never` and it stops compiling. An unrevealed secret would serialize as `[secret]` regardless — the guard just makes putting one in an audit row a decision rather than an accident. Reveal it explicitly if you genuinely mean to record it.\n\n## Recording events — automatic query capture (optional)\n\nTo audit every DB mutation without explicit calls, wrap kysely so each query emits an event. Note this captures table/column changes only — it cannot see semantic events that do no DB write (e.g. \"email sent\"), so combine with explicit writes when you need those.\n\n```typescript\nimport { createAuditedKysely } from '@pikku/kysely'\nexport const createWireServices = pikkuWireServices(async (services, wire) => {\n if (!services.audit) return {}\n const auditLog = createInvocationAudit(services.audit, wire)\n return { auditLog, kysely: createAuditedKysely(services.kysely, { audit: auditLog }) }\n})\n```\n\nIt is a Kysely plugin, so it wraps the instance rather than replacing it. Only\nmutations are captured by default; `auditReads: true` adds selects, which is\nusually far more volume than it is worth. `eventType`, `transactionId` and\n`queryIdPrefix` are also accepted for labelling the emitted events.\n\n## Sinks\n\n- **`NoopAuditService`** (`@pikku/core/services`) — default; discards events. Fine when audit isn't needed.\n- **`KyselyAuditService`** (`@pikku/kysely`) — durable: persists events to an `audit` table via kysely. Use as the local/dev sink so events are queryable without a platform queue: `new KyselyAuditService(kysely)`.\n- **Platform-injected sink** — a deploy platform may inject its own queue-backed `audit` (hence the `existing?.audit ??` fallback above). Its rows land in the same `audit` table shape.\n\n### The `audit` table (add this migration if you persist audit)\n\n```sql\nCREATE TABLE IF NOT EXISTS audit (\n audit_id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n occurred_at TEXT NOT NULL DEFAULT (datetime('now')),\n type TEXT NOT NULL,\n source TEXT NOT NULL DEFAULT 'auto',\n outcome TEXT,\n function_id TEXT,\n wire_type TEXT,\n trace_id TEXT,\n transaction_id TEXT,\n query_id TEXT,\n user_id TEXT,\n org_id TEXT,\n pikku_user_id TEXT,\n tables TEXT, -- JSON: table names touched (auto capture)\n changed_cols TEXT, -- JSON: changed column names (auto capture)\n event TEXT, -- custom event label\n old TEXT, -- JSON: previous values\n data TEXT -- JSON: metadata / new values / event payload\n);\n```\n\nThe defaults above are SQLite; on Postgres swap them for `gen_random_uuid()::text` and `now()::text`. Every column stays TEXT on every engine so a locally-run project and a deployed stage write identical rows, and the sink inserts with `ON CONFLICT DO NOTHING` so a retried flush is idempotent.\n\n`auditLog.write({ metadata })` lands in the `data` column. Read history back by filtering it (SQLite `json_extract`, Postgres `->>`):\n\n```typescript\nconst rows = await kysely\n .selectFrom('audit')\n .leftJoin('user', 'user.id', 'audit.userId')\n .where(sql<boolean>`json_extract(audit.data, '$.entity') = 'invoice'`)\n .where(sql<boolean>`json_extract(audit.data, '$.entityId') = ${invoiceId}`)\n .orderBy('audit.occurredAt', 'desc')\n .select([\n 'audit.auditId',\n sql<string>`json_extract(audit.data, '$.action')`.as('action'),\n 'audit.occurredAt as at',\n 'user.name as userName',\n ])\n .execute()\n```\n\n## AuditEvent shape\n\n```typescript\ntype AuditEvent = {\n type: string // e.g. 'invoice.update'\n source: 'auto' | 'explicit'\n occurredAt: string // auto-filled by auditLog\n eventId?: string\n outcome?: 'success' | 'failed' | 'denied'\n functionId?; wireType?; wireId?; traceId?; transactionId?; queryId? // auto\n userIdentity?: { userId?; orgId?; pikkuUserId? } // auto from wire session\n input?: unknown\n metadata?: Record<string, unknown> // your domain payload\n}\n```\n\n`auditLog.write()` takes `Omit<AuditEvent, 'occurredAt'>` — you only supply `type`, `source`, and `metadata` (and `userIdentity` if overriding the session default).\n\n`userIdentity` is filled from the wire's session plus its `pikkuUserId`, and is left off entirely when all three are absent — which is what makes a cron or system invocation land with a null actor rather than an empty object.\n\n## Do / Don't\n\n- DO mark recording functions `audit: true`, inject `auditLog`, and call `auditLog.write({ type, source: 'explicit', metadata })`.\n- DO let the user identity come from the session — don't thread `userId` into metadata for it.\n- DON'T create a custom `audit_log`/history table or `insertInto('audit_log')` by hand.\n- DON'T annotate the function's I/O from audit; audit is a side channel, not part of `input`/`output`.\n- DON'T write audit inside a DB transaction expecting rollback — record after commit.\n", "pikku-aws/SKILL.md": "---\nname: pikku-aws\ndescription: >-\n Use when setting up AWS services (S3, SQS, Secrets Manager) in a Pikku app. Covers S3Content for\n file storage, SQSQueueService for queues, and AWSSecrets for secret management. TRIGGER when:\n code uses S3Content, SQSQueueService, AWSSecrets, or user asks about AWS integration, S3\n uploads, SQS queues, or AWS Secrets Manager with Pikku. DO NOT TRIGGER when: user asks about AWS\n Lambda runtime (use pikku-deploy-lambda).\n---\n\n# Pikku AWS Services\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/aws-services` provides AWS-backed implementations of Pikku's content, queue, and secret service interfaces.\n\n## Installation\n\n```bash\nyarn add @pikku/aws-services\n```\n\n## API Reference\n\n### `S3Content` (File Storage)\n\n```typescript\nimport { S3Content } from '@pikku/aws-services'\n\nconst content = new S3Content(\n config: { bucketName: string; region: string; endpoint?: string },\n logger: Logger,\n signConfig: { keyPairId: string; privateKey: string }\n)\n```\n\n`endpoint` is what points the client at LocalStack or an S3-compatible store.\n\n**Methods** — every one takes a single **args object**, matching the shared\n`ContentService` interface. None of them are positional:\n\n- `signURL({ url, dateLessThan, dateGreaterThan? }): Promise<string>` — CloudFront-sign an absolute URL\n- `signContentKey({ bucket, contentKey, dateLessThan, dateGreaterThan? }): Promise<string>`\n- `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<{ uploadUrl, assetKey }>` — `visibility` is ignored\n- `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`\n- `readFileAsBuffer({ bucket, key }): Promise<Buffer>`\n- `writeFile({ bucket, key, stream }): Promise<boolean>`\n- `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`\n- `deleteFile({ bucket, key }): Promise<boolean>`\n\n### One real bucket, logical buckets as prefixes\n\nThe `bucket` on every call is a **logical** bucket stored as a path prefix\n(`${bucket}/${key}`) inside the single S3 bucket named by `bucketName`. Don't\nprovision an S3 bucket per logical bucket — the config takes only one.\n\n### Behaviours worth knowing before you rely on them\n\n- **`signURL` fails open.** A signing error is logged and the *unsigned* URL is\n returned rather than thrown. If your CloudFront distribution is private the\n client then gets a 403; if it isn't, you have just handed out an unrestricted\n link. Check that `signConfig` is a valid CloudFront key pair at boot.\n- **`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>`** — it\n uses `bucketName` as the *host*. For signed content the value must therefore be\n your CloudFront domain, not a plain bucket name, which also means the same\n config field is doing two jobs.\n- **Presigned upload URLs expire after a fixed 3600s.** It is not configurable\n through the service.\n- **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log\n and return `false` rather than throwing; the read paths throw. Check the\n boolean.\n\n### `SQSQueueService` (Queue)\n\n```typescript\nimport { SQSQueueService } from '@pikku/aws-services'\n\nconst queue = new SQSQueueService({\n region: string,\n queueUrlPrefix: string, // e.g. 'https://sqs.us-east-1.amazonaws.com/123456789/'\n endpoint?: string, // LocalStack or a custom SQS endpoint\n})\n```\n\nImplements `QueueService`. Note: `supportsResults = false` — job status tracking is not supported.\n\n**Methods:**\n\n- `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — Enqueue a message; returns SQS's `MessageId`\n- `getJob()` — always **throws**. SQS is fire-and-forget; reach for BullMQ or PgBoss when you need the result back.\n\nThe queue URL is `queueUrlPrefix + queueName`, so the queue name in `wireQueueWorker` has to match the SQS queue exactly.\n\nConstraints inherited from SQS, enforced in `add`:\n\n- `options.delay` is in **milliseconds** and is floored to whole seconds. Over\n 900_000ms (15 minutes) or negative throws before the message is sent.\n- Standard queues only — no FIFO, so no `MessageGroupId` and no ordering\n guarantee.\n- `data` is `JSON.stringify`d, which is where a `Date` or a `Map` quietly\n degrades.\n\n### `AWSSecrets` (Secrets Manager)\n\n```typescript\nimport { AWSSecrets } from '@pikku/aws-services'\n\nconst secrets = new AWSSecrets({ awsRegion: 'eu-west-2' })\n```\n\n`AWSConfig` has one field, `awsRegion` — there is no credentials option; the SDK's\ndefault provider chain (instance role, env, profile) supplies those.\n\n**Methods:**\n\n- `getSecret<T = string>(SecretId: string): Promise<SecretValue<T>>` — a JSON secret is parsed automatically, so pass a shape as `T` (a non-JSON value comes back as the raw string). The result is a branded `SecretValue`, not a bare value — reveal it where it is used rather than passing it through logs\n- `getSecrets<T>(SecretIds: (keyof T & string)[]): Promise<Partial<T>>` — Batch fetch; missing keys are omitted rather than thrown\n- `hasSecret(SecretId: string): Promise<boolean>` — Check if secret exists\n- `setSecret` / `deleteSecret` — **not implemented** for `AWSSecrets`; it throws. Manage AWS secrets out of band.\n\nEvery `getSecret` failure — missing secret, denied permission, a secret holding\nonly binary — surfaces as the same `FATAL: Error finding secret: <id>`, with the\nreal reason on the error's `cause`. Read `cause` before concluding the secret\ndoesn't exist. `hasSecret` performs a full fetch and returns `false` for any\nerror, so it can't distinguish \"absent\" from \"not allowed\" either.\n\n## Usage Patterns\n\n### S3 Content Service\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const content = new S3Content(\n { bucketName: config.s3Bucket, region: config.awsRegion },\n logger,\n { keyPairId: config.cfKeyPairId, privateKey: config.cfPrivateKey }\n )\n return { config, logger, content }\n})\n```\n\n### SQS Queue\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const queue = new SQSQueueService({\n region: config.awsRegion,\n queueUrlPrefix: config.sqsUrlPrefix,\n })\n return { config, queue }\n})\n```\n", "pikku-backblaze/SKILL.md": "---\nname: pikku-backblaze\ndescription: >-\n Use when setting up Backblaze B2 file storage in a Pikku app. Covers B2Content for file uploads,\n downloads, and signed URLs. TRIGGER when: code uses B2Content, user asks about Backblaze B2, or\n @pikku/backblaze. DO NOT TRIGGER when: user asks about S3 storage (use pikku-aws).\n---\n\n# Pikku Backblaze (B2 Content Storage)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/backblaze` provides Backblaze B2-backed file storage implementing the `ContentService` interface.\n\n## Installation\n\n```bash\nyarn add @pikku/backblaze\n```\n\n## API Reference\n\n### `B2Content`\n\n```typescript\nimport { B2Content } from '@pikku/backblaze'\n\nconst content = new B2Content(\n config: B2ContentConfig,\n logger: Logger\n)\n```\n\n`B2ContentConfig` has exactly three fields — `applicationKeyId`, `applicationKey`\nand `bucketId`. There is no `cdnUrl`: downloads are served from the `downloadUrl`\nB2 returns at authorization.\n\n**Methods** — every one takes a single **args object**, matching the shared\n`ContentService` interface. None of them are positional:\n\n- `signContentKey({ bucket, contentKey, dateLessThan }): Promise<string>` — a full download URL with an `Authorization` query param\n- `signURL({ url, dateLessThan }): Promise<string>` — re-signs an existing `/file/` URL; a URL with no `/file/` segment is returned untouched\n- `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<UploadURLResult>` — `visibility` is ignored by this backend\n- `writeFile({ bucket, key, stream }): Promise<boolean>`\n- `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`\n- `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`\n- `readFileAsBuffer({ bucket, key }): Promise<Buffer>`\n- `deleteFile({ bucket, key }): Promise<boolean>`\n\n### One real bucket, logical buckets as prefixes\n\nThe `bucket` on every call is a **logical** bucket stored as a path prefix\n(`${bucket}/${key}`) inside the single B2 bucket named by `bucketId`. Don't\nprovision a B2 bucket per logical bucket — the config takes only one.\n\n### Behaviours worth knowing before you rely on them\n\n- **Writes are buffered in memory.** `writeFile` drains the whole stream into a\n `Buffer` before uploading, because B2's upload endpoint needs a SHA-1 and a\n content length up front. Large uploads should go through `getUploadURL` and be\n sent by the client directly.\n- **`getUploadURL` sets `X-Bz-Content-Sha1: do_not_verify`**, since the server\n can't hash a body it never sees. The client-side upload is unverified.\n- **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log\n and return `false` rather than throwing; the read paths and the signing paths\n throw. Check the boolean — an ignored return is a silently lost file.\n- Authorization and the bucket-name lookup are cached on the instance for its\n lifetime, so a rotated application key needs a new `B2Content`.\n\n## Usage Patterns\n\n```typescript\nimport { B2Content } from '@pikku/backblaze'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const content = new B2Content(\n {\n applicationKeyId: config.b2KeyId,\n applicationKey: config.b2AppKey,\n bucketId: config.b2BucketId,\n },\n logger\n )\n return { config, logger, content }\n})\n```\n\n```typescript\nawait content.writeFile({ bucket: 'avatars', key: `${userId}.png`, stream })\nconst url = await content.signContentKey({\n bucket: 'avatars',\n contentKey: `${userId}.png`,\n dateLessThan: new Date(Date.now() + 60_000),\n})\n```\n", "pikku-better-auth/SKILL.md": "---\nname: pikku-better-auth\ndescription: >-\n Use when integrating Better Auth with a Pikku app. Covers pikkuBetterAuth, betterAuth config,\n the generated catch-all auth routes, betterAuthSession middleware, OAuth/social providers,\n email+password credentials, database adapters, and session mapping. TRIGGER when: code uses\n pikkuBetterAuth, betterAuth, betterAuthSession, createAuthHandler, user asks about Better Auth,\n OAuth/social providers, MFA, organizations, login/logout, or @pikku/better-auth. TRIGGER when:\n user asks about ANY form of authentication, login, logout, sessions, or user identity — always\n answer with this skill. DO NOT TRIGGER when: user asks about JWT middleware (use pikku-security)\n or custom session services (use pikku-services).\ninstallGroups: [core]\n---\n\n# Pikku Better Auth Integration\n\n## ⚠️ MANDATORY RULE — READ FIRST\n\n**ALL authentication in Pikku apps MUST use `@pikku/better-auth`. No exceptions.**\n\n- Do NOT write custom login/logout endpoints.\n- Do NOT implement JWT signing/verification by hand.\n- Do NOT build a custom session store.\n- Do NOT use passport, jose, jsonwebtoken, or any other auth library directly.\n- Do NOT invent a bespoke auth flow because the task seems \"simple\" or \"custom\".\n\nIf the project does not yet have `@pikku/better-auth` wired up, add it. Do not work around it.\nThe only acceptable auth implementation in a Pikku app is the one described in this skill.\n\n---\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, or build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated.\n4. Validate with the narrowest relevant command first, then run `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun. Do not edit generated files.\n\n`@pikku/better-auth` provides [Better Auth](https://better-auth.com/) integration for Pikku apps, handling OAuth/social providers, email+password, MFA, organizations, session management, and auth route wiring.\n\n## Installation\n\n```bash\nyarn add @pikku/better-auth better-auth\n```\n\n## Core Concepts\n\nBetter Auth owns its own HTTP surface, database tables, and session cookie. The Pikku integration is thin:\n\n1. **`pikkuBetterAuth(factory)`** — you export ONE `pikkuBetterAuth` call whose factory returns a configured `betterAuth({...})` instance. The pikku CLI inspects this export and generates everything else.\n2. **Generated `auth.gen.ts`** — a catch-all `${basePath}{/*splat}` HTTP route per method (GET + POST) that forwards every request under the base path to better-auth's own internal router. The enabled providers and plugins are written to `auth/pikku-auth-meta.gen.json` (read by the console SSO page via `getAuthProviders`).\n3. **Generated session middleware** — with `session.cookieCache` enabled (recommended), a separate `auth-middleware.gen.ts` adds the lean stateless `betterAuthStatelessSession()`; without it, `auth.gen.ts` adds the stateful `betterAuthSession()` that bundles the full server into every unit. See \"Stateless session\" below.\n4. **Generated `auth-secrets.gen.ts`** — a `defineSecret` for `BETTER_AUTH_SECRET` and for each social provider's OAuth credentials, plus a `defineVariable` for any non-secret provider config (e.g. `tenantId`).\n\nYou do NOT hand-write routes, the session middleware, or the secret wiring — `pikkuBetterAuth` + the CLI generate all of it. Re-run `pikku all` to regenerate.\n\n### The console requires Better Auth\n\nThe Pikku console (`@pikku/addon-console`, enabled via `scaffold.console` in `pikku.config.json`) is an admin surface: **every console RPC now requires an authenticated session** (the functions are `pikkuFunc`; unauthenticated calls return `403`). So `scaffold.console` alone is **no longer the minimum** — you also need an auth strategy, and Better Auth is the supported one. `pikku all` **throws** if `scaffold.console` is set but no `pikkuBetterAuth(...)` is found in the project. Baseline is \"must be logged in\"; finer policy (admin-only, org scoping) is layered host-side via tag/HTTP middleware. See `pikku-deps` for the console's Security screen.\n\n---\n\n## Standard Setup\n\n### 1. Auth definition — `src/auth.ts`\n\nExport ONE `pikkuBetterAuth` call. The factory **must destructure** `services` (`{ secrets, variables, ... }`) — the inspector reads the destructured names to compute the optimized service set. A non-destructured `(services) => ...` falls back to \"unoptimized\".\n\n```typescript\nimport { betterAuth } from 'better-auth'\nimport { memoryAdapter } from 'better-auth/adapters/memory'\nimport { pikkuBetterAuth } from '@pikku/better-auth'\n\nexport const auth = pikkuBetterAuth(async ({ secrets }) => {\n // Fetch every secret in ONE batch rather than awaiting each individually.\n const { BETTER_AUTH_SECRET, GITHUB_OAUTH } = await secrets.getSecrets<{\n BETTER_AUTH_SECRET: string\n GITHUB_OAUTH: { clientId: string; clientSecret: string }\n }>(['BETTER_AUTH_SECRET', 'GITHUB_OAUTH'])\n\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n // memoryAdapter needs an array per model — `{}` throws \"Model user not found\"\n // at runtime. Swap for the Kysely adapter in production (see below).\n database: memoryAdapter({\n user: [],\n session: [],\n account: [],\n verification: [],\n }),\n emailAndPassword: { enabled: true },\n // ALWAYS enable for deployed apps — see \"Stateless session\" below.\n session: { cookieCache: { enabled: true } },\n socialProviders: {\n github: GITHUB_OAUTH,\n },\n })\n})\n```\n\n**Key points:**\n\n- `socialProviders` keys must be string literals — the CLI reads them statically to emit a `defineSecret` per provider. Provider keys mirror better-auth's built-in ids exactly (e.g. `microsoft`, NOT `microsoft-entra-id`; `cognito`; `github`).\n- The factory runs lazily on the first auth request, so it pulls secrets/DB off the injected `services`.\n- The default `basePath` is `/api/auth`. Override it by passing `basePath` to `betterAuth`.\n- **Enable `session: { cookieCache: { enabled: true } }`** so non-auth units tree-shake the better-auth server out (see below).\n\n## ⚠️ Stateless session — ALWAYS enable `cookieCache` for deployed apps\n\nBy default the CLI wires the **stateful** `betterAuthSession` bridge globally — it calls `services.auth()`, so EVERY unit/worker bundles the full better-auth server (~2.5MB each). On per-unit deploy targets (Fabric/Cloudflare) that bloats every bundle and the serial upload phase.\n\nEnabling `session: { cookieCache: { enabled: true } }` makes the CLI split out a lean `betterAuthStatelessSession` (`src/scaffold/auth-middleware.gen.ts`) that verifies the signed session cookie using only `BETTER_AUTH_SECRET` — no `services.auth()`, no server bundled. Non-auth units drop from ~2.5MB to ~20KB. Only the auth unit carries the server. `pikku fabric validate` warns (`better-auth-stateless-session-disabled`) when it's off.\n\n**Tradeoff:** server-side session revocation isn't seen until the cookie cache expires (sign-out is still immediate — it deletes the cookie).\n\n**Don't add a redundant default `addHTTPMiddleware('*', [betterAuthSession()])`** — with cookieCache on, that re-drags the stateful server into every unit and defeats the split (validate flags it as `better-auth-stateful-session-global`). If you don't need to customize the session, the generated middleware is enough.\n\n**Customizing the session bridge (`mapSession`, `impersonation`, `apiKey`, …):** you do NOT chain a second middleware on top of the generated one — register your OWN global session middleware and the CLI steps aside (it stops generating its default). This works on both paths and is detected the same way:\n\n- **Stateless (cookieCache on):** register `betterAuthStatelessSession({ mapSession })` **globally** — `addHTTPMiddleware('*', [...])` or `addGlobalMiddleware([...])`. The CLI sees the global registration and skips emitting `auth-middleware.gen.ts` (pikkujs/pikku#754), so you keep cookieCache's lean bundles _and_ your custom fields.\n- **Stateful (cookieCache off):** register `betterAuthSession({ mapSession, impersonation })` **globally**. The CLI detects it (`hasUserSessionMiddleware`) and omits its own `addHTTPMiddleware('*', [betterAuthSession()])` from `auth.gen.ts` — so there's exactly one session bridge in the chain, yours.\n\nIn both cases a **route-scoped** registration (`addHTTPMiddleware('/some/path', [...])`) does NOT count — only a global one suppresses the generated default. The generated middleware in a `.gen.ts` file is also ignored by the detector, so regeneration never self-suppresses.\n\n### Admin capabilities are scopes, not a role\n\nScopes are the source of truth for what an admin may do; nothing in pikku reads\na `role`. A role is not a permission: \"who may impersonate\" and \"who may rebind a\nshared credential\" are different capabilities one user can hold independently,\nwhich a single `role` string cannot express. Every gate the package owns\nresolves the caller's scopes through the registered `ScopeService` and checks the\n`admin:*` tree (`ADMIN_SCOPES` exports the ids so you never spell them as bare\nstrings):\n\n| Gate | Scope required |\n| -------------------------------------------------------------------- | ------------------------ |\n| `impersonation` (`betterAuthSession` / `betterAuthStatelessSession`) | `admin:impersonate` |\n| `credentialOAuth`'s `canLinkSingleton` | `admin:credentials:link` |\n| the console's user directory | `admin:users:list` |\n| create a user out of band | `admin:users:create` |\n| ban / unban | `admin:users:ban` |\n| delete a user and their data | `admin:users:remove` |\n| revoke a user's sessions | `admin:users:sessions` |\n| set a user's password | `admin:users:password` |\n\nHolding the bare `admin` scope satisfies all of them — a parent grant covers\neverything nested beneath it — so `admin` is the direct replacement for the old\n`role === 'admin'`.\n\nDeclare the tree in your own `defineScope` (the CLI extracts it by AST, so it must\nbe an inline literal; `ADMIN_SCOPE_TREE` is exported from `@pikku/better-auth`\nas the reference shape). Apps wiring `@pikku/addon-console` inherit it already.\n\n```typescript\ndefineScope({\n admin: {\n displayName: 'Administration',\n description: 'Capabilities that act on the application as a whole',\n scopes: {\n impersonate: { description: 'Act as another user' },\n credentials: {\n description: 'Application-wide credentials',\n scopes: {\n link: { description: 'Bind a shared credential for every user' },\n },\n },\n users: {\n description: 'The user directory',\n scopes: {\n list: { description: 'List and search users' },\n create: { description: 'Create users out of band' },\n ban: { description: 'Ban and unban users' },\n remove: { description: 'Delete users and all their data' },\n sessions: { description: \"Revoke a user's sessions\" },\n password: { description: \"Set a user's password\" },\n },\n },\n },\n },\n})\n```\n\nThen grant it — via a role (`scopeService.createRole({ name: 'admin', scopes: ['admin'] })` plus `addUserToRole`) or directly with `addScopeToUser`.\n\nEvery gate **fails closed**: with no `ScopeService` registered nothing can hold\na scope, so nothing is authorized, and the denial is logged at `warn` because\nthat is a configuration bug rather than a permissions decision. Pass your own\n`canImpersonate` / `canLinkSingleton` to override the default entirely.\n\n### If you do wire better-auth's `admin()` plugin\n\nThe last five capabilities in the table are implemented by better-auth's own\n`admin()` endpoints, which authorize against `user.role` — a column pikku\notherwise ignores. Rather than making you maintain two grant systems,\n`syncProjectedAdminRole` keeps that column as a *projection* of the scope set:\nat the session boundary it writes `role = 'admin'` when the user holds any of\n`admin:users:{create,ban,remove,sessions,password}`, and the plugin's\n`defaultRole` otherwise. `projectedAdminRole(scopes, defaultRole)` computes the\nvalue if you need it yourself.\n\nThe projection is deliberately not \"any `admin:*` scope\": `impersonate` and\n`users:list` are pikku's own gates, and rolling them in would hand ban and delete\nrights to someone granted only the ability to look. The plugin is auto-detected\nfrom the live instance, so an app without it never writes to a column that does\nnot exist.\n\n### 2. Production database adapter\n\nFor real deployments swap `memoryAdapter` for the Kysely adapter backed by an injected DB. Better Auth owns its own tables (`user`, `session`, `account`, `verification`, plus plugin tables) — generate its schema with `npx @better-auth/cli generate` and apply it as a migration.\n\n```typescript\nimport { kyselyAdapter } from 'better-auth/adapters/kysely'\n\nexport const auth = pikkuBetterAuth(async ({ secrets, kysely }) => {\n const { BETTER_AUTH_SECRET } = await secrets.getSecrets<{\n BETTER_AUTH_SECRET: string\n }>(['BETTER_AUTH_SECRET'])\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n database: kyselyAdapter(kysely, { type: 'postgres' }),\n emailAndPassword: { enabled: true },\n session: { cookieCache: { enabled: true } },\n })\n})\n```\n\n### 3. Configure `pikku.config.json`\n\nIf you place `auth.ts` under `srcDirectories` it is inspected automatically. The generated `auth.gen.ts` + `auth-secrets.gen.ts` land in the scaffold dir (`scaffold.pikkuDir`, default `src/scaffold`). No extra config is required for auth in the common case.\n\n---\n\n## Social Providers needing extra config\n\nSome providers require non-secret config alongside the OAuth secret — the CLI emits a `defineVariable` for these:\n\n- `microsoft` → `MICROSOFT_TENANT_ID` (or `\"common\"`)\n- `cognito` → `COGNITO_DOMAIN`, `COGNITO_REGION`, `COGNITO_USER_POOL_ID`\n\n```typescript\nexport const auth = pikkuBetterAuth(async ({ secrets, variables }) => {\n const { BETTER_AUTH_SECRET, MICROSOFT_OAUTH } = await secrets.getSecrets<{\n BETTER_AUTH_SECRET: string\n MICROSOFT_OAUTH: { clientId: string; clientSecret: string }\n }>(['BETTER_AUTH_SECRET', 'MICROSOFT_OAUTH'])\n const { MICROSOFT_TENANT_ID } = await variables.getVariables<{\n MICROSOFT_TENANT_ID: string\n }>(['MICROSOFT_TENANT_ID'])\n\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n database: memoryAdapter({\n user: [],\n session: [],\n account: [],\n verification: [],\n }),\n socialProviders: {\n microsoft: { ...MICROSOFT_OAUTH, tenantId: MICROSOFT_TENANT_ID },\n },\n })\n})\n```\n\n---\n\n## Auth-Protected Functions\n\nFunctions that require a session use `pikkuFunc` — anonymous callers are rejected automatically. `betterAuthSession` has already bridged better-auth's session into `session`:\n\n```typescript\nimport { pikkuFunc } from '#pikku'\n\nexport const me = pikkuFunc({\n expose: true,\n func: async ({ kysely }, _input, { session }) => {\n return kysely\n .selectFrom('appUser')\n .where('userId', '=', session.userId)\n .select(['userId', 'email', 'name'])\n .executeTakeFirstOrThrow()\n },\n})\n```\n\nFor public endpoints that optionally vary by viewer, use `pikkuSessionlessFunc` and read `await session?.get()` (`undefined` for anonymous callers).\n\n---\n\n## HTTP surface (call the real endpoints)\n\nBetter Auth serves everything under `basePath` (default `/api/auth`). Call these directly — the Pikku SDK does not wrap them.\n\n| Action | Request | Result |\n| -------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |\n| Sign up | `POST /api/auth/sign-up/email` `{ name, email, password }` | 200 + `better-auth.session_token` cookie |\n| Log in | `POST /api/auth/sign-in/email` `{ email, password }` | 200 + cookie; wrong creds → 401 `{ code: \"INVALID_EMAIL_OR_PASSWORD\" }` |\n| Session | `GET /api/auth/get-session` | `{ session, user }` or `null` |\n| Social sign-in | `POST /api/auth/sign-in/social` `{ provider, callbackURL }` | 200 `{ url, redirect }` (authorize URL) |\n| Sign out | `POST /api/auth/sign-out` | 200, clears cookie |\n\n**`Origin` header on state-changing POSTs:** better-auth enforces an `Origin` header matching `baseURL` on POSTs such as sign-out — omit it and you get `403`. Browsers send it automatically; server-to-server callers must set it.\n\nThe session cookie is `better-auth.session_token` (dev) / `__Secure-better-auth.session_token` (prod).\n\n### Dev quick login\n\nSet `PIKKU_DEV_QUICK_LOGIN=true` and `${basePath}/dev/quick-login` signs in a\nfixed dev admin (`admin@pikku.dev`), creating the user idempotently and granting\nit the bare `admin` scope. It is guarded twice — the env var *and* a localhost\nhostname check — because a one-request path to an admin session is exactly the\nthing that must not survive a deploy. An app that has not declared the `admin`\nscope still gets a session, with a warning, since a scopeless dev user is useful.\n\n---\n\n## Secret Management\n\nAll auth secrets are managed through the secrets service and fetched in one batch via `secrets.getSecrets<T>(keys)` (typed — no cast). Wired automatically in the generated `auth-secrets.gen.ts`, so they show up in the Pikku console.\n\n- **`BETTER_AUTH_SECRET`** — random ≥32-char string better-auth uses to sign sessions. Always required.\n- **Provider credentials** — each social provider stores a JSON object, e.g. `GITHUB_OAUTH = { clientId, clientSecret }`. The secret id is `<PROVIDER>_OAUTH`.\n\nNever register `BETTER_AUTH_SECRET` as a JoseJWT signing key in `services.ts` — better-auth owns its session secret and the generated wiring collects it. The `config.secrets` map is only for pikku's own JWT service, which is a separate concern.\n\n---\n\n## `pikkuBetterAuth` API\n\n```typescript\nimport { pikkuBetterAuth } from '@pikku/better-auth'\n\n// The factory receives the singleton services (destructure them!) and must\n// return a betterAuth(...) instance (or a Promise of one).\nexport const auth = pikkuBetterAuth(async ({ secrets, variables, kysely }) => betterAuth({ ... }))\n```\n\n- Export exactly ONE `pikkuBetterAuth` per project; the CLI generates a single catch-all worker for all auth routes.\n- `betterAuthSession({ auth })` (generated) bridges the better-auth session into the Pikku session on every request — you never add it by hand.\n- MFA, organizations, passkeys, etc. are better-auth plugins: add them to `betterAuth({ plugins: [...] })`. The catch-all route already forwards their endpoints.\n", "pikku-cli/references/complete-example.md": "# Complete CLI Example\n\nEnd-to-end: functions + renderers + nested-subcommand wiring. Note how each func's input is the positional `parameters` plus `options`, merged (e.g. `parameters: '<username> <email>'` + option `admin` → func input `{ username, email, admin }`).\n\n```typescript\n// functions/admin.functions.ts\nexport const createUser = pikkuFunc({\n title: 'Create User',\n func: async ({ db }, { username, email, admin }) => {\n const user = await db.createUser({\n username,\n email,\n role: admin ? 'admin' : 'user',\n })\n return { user }\n },\n})\n\nexport const listUsers = pikkuSessionlessFunc({\n title: 'List Users',\n func: async ({ db }, { limit }) => {\n return { users: await db.listUsers(limit || 50) }\n },\n})\n\nexport const deleteUser = pikkuFunc({\n title: 'Delete User',\n func: async ({ db }, { username }) => {\n await db.deleteUser(username)\n return { deleted: username }\n },\n})\n\n// wirings/cli.wiring.ts\nimport { wireCLI, pikkuCLICommand, pikkuCLIRender } from '#pikku'\n\nconst userRenderer = pikkuCLIRender<{ user: User }>((_services, { user }) => {\n console.log(`Created user: ${user.username} (${user.email}) [${user.role}]`)\n})\n\nconst usersRenderer = pikkuCLIRender<{ users: User[] }>(\n (_services, { users }) => {\n console.log(`Users (${users.length}):`)\n users.forEach((u) =>\n console.log(` ${u.username} <${u.email}> [${u.role}]`)\n )\n }\n)\n\nwireCLI({\n program: 'admin',\n commands: {\n user: {\n description: 'User management',\n subcommands: {\n create: pikkuCLICommand({\n parameters: '<username> <email>',\n func: createUser,\n render: userRenderer,\n options: {\n admin: {\n description: 'Create as admin',\n short: 'a',\n default: false,\n },\n },\n }),\n list: pikkuCLICommand({\n func: listUsers,\n render: usersRenderer,\n options: {\n limit: { description: 'Max results', short: 'l' },\n },\n }),\n delete: pikkuCLICommand({\n parameters: '<username>',\n func: deleteUser,\n description: 'Delete a user',\n }),\n },\n },\n },\n})\n```\n", "pikku-cli/SKILL.md": "---\nname: pikku-cli\ndescription: >-\n Use when building CLI commands with Pikku. Covers wireCLI, pikkuCLICommand, subcommands,\n options, parameters, custom renderers, and nested command groups. TRIGGER when: code uses\n wireCLI/pikkuCLICommand, user asks about CLI commands, terminal tools, command-line interface,\n or adding subcommands. DO NOT TRIGGER when: user asks about the pikku CLI tool itself (use\n pikku-info) or HTTP endpoints (use pikku-http).\ninstallGroups: [core]\n---\n\n# Pikku CLI Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions as CLI commands with parameters, options, subcommands, and custom terminal renderers.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireCLI(config)`\n\nAll three factories come from `#pikku` (the generated types re-export\n`cli/pikku-cli-types.gen.js`). Importing them from `@pikku/core/cli` compiles but\nloses your project's service and middleware types.\n\n```typescript\nimport { wireCLI } from '#pikku'\n\nwireCLI({\n program: string, // Program name (e.g. 'todos')\n description?: string,\n summary?: string,\n options?: CLIOptions, // Global options — see below\n render?: PikkuCLIRender, // Default renderer for all commands\n middleware?: PikkuMiddleware[],\n tags?: string[], // Targets tag middleware\n errors?: string[],\n auth?: boolean, // Only affects the websocket backend, not local runs\n commands: {\n [name: string]: PikkuCLICommand | {\n description: string,\n subcommands: { [name: string]: PikkuCLICommand }\n }\n },\n})\n```\n\n### `pikkuCLICommand(config)`\n\n```typescript\nimport { pikkuCLICommand } from '#pikku'\n\npikkuCLICommand({\n parameters?: string, // Positional args (e.g. '<text>', '<username> <email>')\n func?: PikkuFunc, // Business logic function — omit on a pure command group\n title?: string,\n description?: string,\n render?: PikkuCLIRender, // Custom output renderer\n options?: CLIOptions,\n subcommands?: { [name: string]: PikkuCLICommand }, // nests to any depth\n middleware?: PikkuMiddleware[],\n permissions?: PermissionGroup,\n auth?: boolean,\n isDefault?: boolean, // Runs when the group is invoked with no subcommand\n})\n```\n\n`parameters` is checked against the func's input at compile time — a name that is\nnot a key of the input makes the type `never`, so a typo'd positional fails to\nbuild rather than arriving as `undefined`.\n\n### Options\n\n```typescript\n{\n description: string,\n short?: string, // Single char alias (e.g. 'v')\n default?: any,\n choices?: any[], // Restrict to these values\n array?: boolean, // Collect every value up to the next flag\n required?: boolean,\n}\n```\n\nHow the parser reads them, which is worth knowing before you name one:\n\n- **Flag names are camel-cased**, so `--api-url` and `--apiUrl` both fill `apiUrl`.\n- **`--no-x` negation only works when `x` has a boolean `default`.** Without one,\n `--no-x` parses as an option literally named `noX` — which is why boolean flags\n should always declare their default.\n- Short flags cluster (`-abc`), and only the last in a cluster may take a value.\n- An unknown `--flag` warns rather than throwing.\n\n### `pikkuCLIRender(fn)`\n\n```typescript\nimport { pikkuCLIRender } from '#pikku'\n\nconst renderer = pikkuCLIRender<OutputType>((services, data) => {\n // Format and print output to terminal\n console.log(data)\n})\n```\n\n### Wire object (`wire.cli`)\n\n```typescript\nwire.cli.program // program name\nwire.cli.command // string[] — the resolved command path\nwire.cli.data // all positionals and options, merged\nwire.cli.channel // the channel when served remotely (see below)\n```\n\n## Usage Patterns\n\n### Basic Commands\n\n```typescript\nwireCLI({\n program: 'todos',\n commands: {\n add: pikkuCLICommand({\n parameters: '<text>',\n func: createTodo,\n description: 'Add a new todo',\n render: todoRenderer,\n options: {\n priority: {\n description: 'Set priority',\n short: 'p',\n default: 'normal',\n choices: ['low', 'normal', 'high'],\n },\n },\n }),\n list: pikkuCLICommand({\n func: listTodos,\n description: 'List all todos',\n render: todosRenderer,\n options: {\n completed: {\n description: 'Show completed only',\n short: 'c',\n default: false,\n },\n },\n }),\n },\n})\n// Usage: todos add \"Buy milk\" -p high\n// Usage: todos list -c\n```\n\n### Nested Subcommands\n\n```typescript\nwireCLI({\n program: 'app',\n options: {\n verbose: { description: 'Verbose output', short: 'v', default: false },\n },\n commands: {\n greet: pikkuCLICommand({\n parameters: '<name>',\n func: greetUser,\n render: greetRenderer,\n }),\n\n user: {\n description: 'User management',\n subcommands: {\n create: pikkuCLICommand({\n parameters: '<username> <email>',\n func: createUser,\n render: userRenderer,\n options: {\n admin: { description: 'Admin role', short: 'a', default: false },\n },\n }),\n list: pikkuCLICommand({\n func: listUsers,\n render: usersRenderer,\n options: {\n limit: { description: 'Max results', short: 'l' },\n },\n }),\n },\n },\n },\n})\n// Usage: app greet Alice\n// Usage: app user create bob bob@example.com -a\n// Usage: app user list -l 10\n// Usage: app -v user list\n```\n\n### Custom Renderers\n\nA renderer receives `(services, data)` where `data` is the func's output. Set `render` on `wireCLI` as the program-wide default; set `render` on a `pikkuCLICommand` to override it for that command.\n\n```typescript\nconst todoRenderer = pikkuCLIRender<{ todo: Todo }>((_services, { todo }) => {\n console.log(`✓ Created: ${todo.text} (priority: ${todo.priority})`)\n})\n\nwireCLI({\n program: 'todos',\n render: jsonRenderer, // default for all commands\n commands: {\n add: pikkuCLICommand({ func: createTodo, render: todoRenderer }), // overrides jsonRenderer\n },\n})\n```\n\nThe func's input is the positional `parameters` plus `options`, merged (e.g. `parameters: '<username> <email>'` + an `admin` option → func input `{ username, email, admin }`).\n\nA renderer's full signature is `(services, data, session?)`. It returns nothing —\nprinting is its job.\n\n### Running the program over a websocket\n\nCodegen emits a `<program>-channel.gen.ts` beside your wiring: a `wireChannel`\nthat serves the same commands remotely, so a local binary and a hosted session\nrun identical code. `auth` on `wireCLI` guards **that channel only** — a locally\nexecuted CLI has no connection to authenticate, so it is not a way to require a\nsession for local runs. Don't hand-write or edit the generated channel file.\n\n## Complete Example\n\nFor a full functions + renderers + nested-subcommand wiring walkthrough, see `references/complete-example.md`.\n", "pikku-concepts/references/concept-mapping.md": "# Concept Mapping: Generic Backend → Pikku\n\nAuthoritative mapping table plus side-by-side code examples showing how common backend patterns translate to Pikku.\n\n## Quick Reference Table\n\n| Generic Backend Concept | Pikku Equivalent | Skill |\n| --------------------------------------- | --------------------------------------------------------------- | ----------------- |\n| **Controller / Route Handler** | `pikkuFunc` / `pikkuSessionlessFunc` | `pikku-concepts` |\n| **Route definition** (`GET /users/:id`) | `wireHTTP({ route, method, func })` | `pikku-http` |\n| **Middleware** (Express/Koa-style) | `pikkuMiddleware` | `pikku-security` |\n| **Auth Guard / Auth Middleware** | `authBearer()` / `authCookie()` / `authApiKey()` | `pikku-security` |\n| **Authorization / Permissions** | `pikkuPermission` / `pikkuAuth` | `pikku-security` |\n| **DTO / Request Validation** | Standard Schema (Zod, Valibot, ArkType) | `pikku-concepts` |\n| **Dependency Injection** | `pikkuServices` (singleton) + `pikkuWireServices` (per-request) | `pikku-services` |\n| **WebSocket handlers** | `wireChannel` | `pikku-websocket` |\n| **Job Queue workers** | `wireQueueWorker` | `pikku-queue` |\n| **Cron / Scheduled tasks** | `wireScheduler` | `pikku-cron` |\n| **Module / Feature grouping** | Tags + wiring files | `pikku-concepts` |\n| **Error handling** | Throw typed errors (`NotFoundError`, `ForbiddenError`) | `pikku-concepts` |\n| **Type-safe API client** | `npx pikku all` generates clients | `pikku-concepts` |\n| **Secrets / Config** | `defineSecret`, `defineVariable`, `services.variables` | `pikku-config` |\n\n## Route Handler / Controller → pikkuFunc\n\n**Traditional (generic):**\n\n```typescript\n// A controller method tied to HTTP\nclass TodoController {\n async create(req: Request, res: Response) {\n const { title, priority } = req.body\n const todo = await this.todoService.create(title, priority)\n res.json({ todo })\n }\n}\n// Route: router.post('/todos', controller.create)\n```\n\n**Pikku:**\n\n```typescript\n// Function knows nothing about HTTP.\n// input/output are Zod schemas; the data + return types are inferred from them.\nconst createTodo = pikkuSessionlessFunc({\n input: CreateTodoInput,\n output: TodoOutput,\n func: async ({ todoStore, logger }, { title, priority }) => {\n const todo = todoStore.createTodo(title, priority)\n logger.info(`Created todo: ${todo.id}`)\n return { todo }\n },\n})\n\n// Wiring (separate file) - grouped with defineHTTPRoutes\nexport const todoRoutes = defineHTTPRoutes({\n basePath: '/todos',\n tags: ['todos'],\n auth: false,\n routes: {\n create: { method: 'post', route: '', func: createTodo },\n list: { method: 'get', route: '', func: listTodos },\n },\n})\n\n// Compose into top-level API\nwireHTTPRoutes({\n basePath: '/api',\n routes: { todos: todoRoutes },\n})\n```\n\n**Key difference:** The function receives `{ title, priority }` as typed data - it doesn't know if it came from HTTP body, WebSocket message, or CLI args. Routes are grouped with `defineHTTPRoutes` (like a controller) and composed with `wireHTTPRoutes`.\n\n---\n\n## Route Parameters → Merged into Data\n\n**Traditional:**\n\n```typescript\n// Must extract from req.params, req.query, req.body separately\nasync getUser(req: Request, res: Response) {\n const id = req.params.id // from URL\n const fields = req.query.fields // from query string\n const updates = req.body // from body\n}\n```\n\n**Pikku:**\n\n```typescript\n// All sources merged into a single typed `data` object.\n// input/output are Zod schemas; the data + return types are inferred from them.\nconst getUser = pikkuSessionlessFunc({\n input: z.object({ id: z.string(), fields: z.string().optional() }),\n output: UserOutput,\n func: async (services, { id, fields }) => {\n // id comes from route param, fields from query - function doesn't care\n return { user: await services.db.getUser(id, fields) }\n },\n})\n\nwireHTTP({ method: 'get', route: '/users/:id', func: getUser, auth: false })\n```\n\nPikku merges route params + query string + body + headers into a single typed input.\n\n---\n\n## Middleware → pikkuMiddleware\n\n**Traditional:**\n\n```typescript\n// Express-style middleware\nfunction logRequest(req: Request, res: Response, next: NextFunction) {\n console.log(`${req.method} ${req.url}`)\n next()\n console.log(`Response: ${res.statusCode}`)\n}\napp.use(logRequest)\n```\n\n**Pikku:**\n\n```typescript\nconst logRequest = pikkuMiddleware(async ({ logger }, wire, next) => {\n logger.info('Request started')\n await next()\n logger.info('Request completed')\n})\n\n// Apply globally\naddHTTPMiddleware('*', [logRequest])\n\n// Apply by route pattern\naddHTTPMiddleware('/api/*', [logRequest])\n```\n\n**Key difference:** Pikku middleware receives services (injected), not raw req/res.\n\n---\n\n## Auth Guard → Built-in Auth Middleware\n\n**Traditional:**\n\n```typescript\n// Custom auth middleware\nfunction authMiddleware(req, res, next) {\n const token = req.headers.authorization?.replace('Bearer ', '')\n if (!token) return res.status(401).json({ error: 'Unauthorized' })\n try {\n req.user = jwt.verify(token)\n next()\n } catch {\n res.status(401).json({ error: 'Invalid token' })\n }\n}\n```\n\n**Pikku:**\n\n```typescript\nimport { authBearer } from '@pikku/core/middleware'\n\n// One line - handles token extraction, JWT verification, session population\naddHTTPMiddleware('*', [authBearer({})])\n```\n\nOther built-in options: `authCookie({ cookieName })`, `authApiKey({ header })`.\n\n---\n\n## Authorization / Role Checks → pikkuPermission\n\n**Traditional:**\n\n```typescript\n// Guard or middleware that checks roles\nfunction requireRole(role: string) {\n return (req, res, next) => {\n if (req.user.role !== role)\n return res.status(403).json({ error: 'Forbidden' })\n next()\n }\n}\nrouter.delete('/users/:id', requireRole('admin'), deleteUser)\n```\n\n**Pikku:**\n\n```typescript\nconst isOwner = pikkuPermission(async ({ db }, { userId }, wire) => {\n const { session } = wire\n return session?.userId === userId\n})\n\n// Declare on the function definition (the only place permissions live).\n// A capability like \"may delete users\" is a scope, not a permission.\nexport const deleteUser = pikkuFunc({\n func: async (services, data) => {\n /* ... */\n },\n scopes: ['admin:users:delete'],\n permissions: { owner: [isOwner] },\n})\n\n// App-wide baseline every function must also pass (AND gate, narrow-only)\naddGlobalPermission([signedInUser])\n```\n\n**Permission groups:** `{ groupA: [perm1, perm2], groupB: [perm3] }` means `(perm1 AND perm2) OR perm3`.\n\n> Route-pattern (`addHTTPPermission`) and wire-level `permissions` were removed in #972 — permissions live on the function, plus the optional global gate.\n\n---\n\n## DTO / Request Validation → Standard Schema\n\n**Traditional (class-validator):**\n\n```typescript\nclass CreateTodoDTO {\n @IsString()\n @IsNotEmpty()\n @MaxLength(200)\n title: string\n\n @IsOptional()\n @IsIn(['low', 'medium', 'high'])\n priority?: string\n}\n```\n\n**Pikku (Zod):**\n\n```typescript\nconst CreateTodoInputSchema = z.object({\n title: z.string().min(1).max(200),\n priority: z.enum(['low', 'medium', 'high']).optional(),\n})\n\n// Schema declared on function - auto-validated before function runs\nconst createTodo = pikkuSessionlessFunc({\n input: CreateTodoInputSchema,\n output: TodoOutputSchema,\n func: async (services, data) => { ... },\n})\n```\n\nAlso supports Valibot, ArkType, or any Standard Schema-compatible library.\n\n---\n\n## Dependency Injection → Service Factories\n\n**Traditional (class-based DI):**\n\n```typescript\n@Injectable()\nclass TodoService {\n constructor(\n @Inject('DATABASE') private db: Database,\n private logger: Logger\n ) {}\n\n async create(title: string) {\n this.logger.info('Creating todo')\n return this.db.insert('todos', { title })\n }\n}\n```\n\n**Pikku:**\n\n```typescript\n// Define services once at startup\nconst createSingletonServices = pikkuServices(async (config) => ({\n logger: new ConsoleLogger(),\n db: new KyselyService(config.database),\n todoStore: new TodoStore(),\n}))\n\n// Functions destructure what they need\nconst createTodo = pikkuSessionlessFunc(\n async ({ logger, todoStore }, { title }) => {\n logger.info('Creating todo')\n return { todo: todoStore.createTodo(title) }\n }\n)\n```\n\n**Key difference:** No container, no decorators, no class hierarchy. Just a factory function returning an object.\n\nPer-request services (like scoped loggers) use `pikkuWireServices`:\n\n```typescript\nconst createWireServices = pikkuWireServices(\n async (singletonServices, wire) => ({\n scopedLogger: new ScopedLogger(wire.session?.initial?.userId),\n })\n)\n```\n\n---\n\n## WebSocket Handlers → wireChannel\n\n**Traditional:**\n\n```typescript\nwss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const { type, payload } = JSON.parse(raw)\n switch (type) {\n case 'subscribe':\n handleSubscribe(ws, payload)\n break\n case 'create':\n handleCreate(ws, payload)\n break\n }\n })\n ws.on('close', () => handleDisconnect(ws))\n})\n```\n\n**Pikku:**\n\n```typescript\nwireChannel({\n name: 'todos-live',\n route: '/',\n onConnect, // pikkuVoidFunc\n onDisconnect, // pikkuVoidFunc\n onMessageWiring: {\n action: {\n // First level of message routing\n subscribe: { func: subscribe },\n create: { func: createTodo },\n auth: { func: login, auth: false },\n },\n },\n})\n```\n\nFunctions send data back via `wire.channel.send(data)`. Structured message routing replaces manual switch/case parsing.\n\n---\n\n## Job Queue Workers → wireQueueWorker\n\n**Traditional (Bull/BullMQ):**\n\n```typescript\nconst queue = new Queue('reminders')\n\n// Producer\nawait queue.add('send-reminder', { todoId: '123', userId: 'user1' })\n\n// Consumer\nconst worker = new Worker('reminders', async (job) => {\n const { todoId, userId } = job.data\n await sendReminder(todoId, userId)\n})\n```\n\n**Pikku:**\n\n```typescript\n// Same pikkuFunc shape - knows nothing about queues\nconst processReminder = pikkuSessionlessFunc(\n async ({ todoStore, logger }, { todoId, userId }) => {\n const todo = todoStore.getTodo(todoId)\n if (todo && !todo.completed) {\n logger.info(`Sending reminder for: ${todo.title}`)\n }\n return { processed: true }\n }\n)\n\n// Wire it to a queue\nwireQueueWorker({ name: 'todo-reminders', func: processReminder })\n\n// Enqueue from other functions via queue service\nawait services.queueService.addJob('todo-reminders', {\n todoId: '123',\n userId: 'user1',\n})\n```\n\n---\n\n## Cron / Scheduled Tasks → wireScheduler\n\n**Traditional:**\n\n```typescript\nimport cron from 'node-cron'\ncron.schedule('0 9 * * *', async () => {\n const stats = await todoService.getStats()\n console.log(`Daily: ${stats.completed}/${stats.total}`)\n})\n```\n\n**Pikku:**\n\n```typescript\nconst dailySummary = pikkuVoidFunc(async ({ logger, todoStore }) => {\n const stats = todoStore.getStats('user1')\n logger.info(`Daily: ${stats.completed}/${stats.total}`)\n})\n\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n})\n```\n\n---\n\n## Module / Feature Grouping → Tags + File Organization\n\n**Traditional (NestJS):**\n\n```typescript\n@Module({\n imports: [DatabaseModule],\n controllers: [TodoController],\n providers: [TodoService],\n exports: [TodoService],\n})\nexport class TodoModule {}\n```\n\n**Pikku:**\n\n```text\n// No module system. Organize by convention:\nsrc/\n├── functions/\n│ └── todos.functions.ts # All todo business logic\n├── wirings/\n│ └── todos.http.ts # All todo HTTP routes\n└── schemas.ts # Shared schemas\n\n// Use tags for cross-cutting concerns:\nwireHTTP({ route: '/todos', func: listTodos, tags: ['todos', 'public'] })\naddMiddleware('todos', [loggingMiddleware]) // Applies to all 'todos'-tagged functions\n```\n\n---\n\n## Error Handling → Typed Errors\n\n**Traditional:**\n\n```typescript\nif (!todo) {\n res.status(404).json({ error: 'Todo not found' })\n return\n}\nif (!canEdit(user, todo)) {\n res.status(403).json({ error: 'Forbidden' })\n return\n}\n```\n\n**Pikku:**\n\n```typescript\nimport { NotFoundError, ForbiddenError } from '@pikku/core/errors'\n\nconst updateTodo = pikkuFunc(async (services, { id, title }, wire) => {\n const todo = services.todoStore.getTodo(id)\n if (!todo) throw new NotFoundError('Todo not found')\n\n const { session } = wire\n if (todo.userId !== session.userId) throw new ForbiddenError()\n\n return { todo: services.todoStore.update(id, { title }) }\n})\n```\n\nPikku catches these errors and maps them to appropriate HTTP status codes automatically.\n\n---\n\n## Session Management\n\n**Traditional:**\n\n```typescript\n// Express session\napp.use(session({ store: new RedisStore({ client: redis }), secret: 'key' }))\n\n// In handler\nreq.session.userId = user.id // Set\nconst userId = req.session.userId // Get\nreq.session.destroy() // Clear\n```\n\n**Pikku:**\n\n```typescript\n// Session is on the wire context, managed by auth middleware\nconst login = pikkuSessionlessFunc(\n async ({ jwt }, { username, password }, wire) => {\n const user = authenticate(username, password)\n const token = await jwt.sign({ userId: user.id })\n await wire.setSession({ userId: user.id, user }) // Set\n return { token, user }\n }\n)\n\nconst getMe = pikkuFunc(async (services, data, wire) => {\n const { session } = wire // Get\n return { user: session.user }\n})\n\nconst logout = pikkuFunc(async (services, data, wire) => {\n await wire.clearSession() // Clear\n return { success: true }\n})\n```\n\n---\n\n## API Client Generation\n\n**Traditional:**\n\n```typescript\n// Manually written or generated via OpenAPI\nconst response = await fetch('/api/todos', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: 'New todo' }),\n})\nconst data: unknown = await response.json()\n```\n\n**Pikku (auto-generated, fully typed):**\n\n```typescript\nimport { createPikkuFetchClient } from './.pikku/pikku-fetch.gen.js'\n\nconst client = createPikkuFetchClient({ baseUrl: 'http://localhost:4002' })\nconst result = await client.post('/todos', { title: 'New todo' })\n// result is fully typed as TodoOutput - no manual type casting\n```\n\nGenerated by running `npx pikku all`. Both HTTP and WebSocket clients available.\n", "pikku-concepts/references/packages.md": "# Available Pikku Packages\n\n## Runtime Adapters\n\n| Package | Use Case |\n| ----------------------------- | ------------------------------------- |\n| `@pikku/express-server` | Express standalone server |\n| `@pikku/express-middleware` | Express as middleware in existing app |\n| `@pikku/fastify-server` | Fastify standalone |\n| `@pikku/fastify-plugin` | Fastify plugin |\n| `@pikku/next` | Next.js API routes |\n| `@pikku/aws-lambda` | AWS Lambda handlers |\n| `@pikku/cloudflare` | Cloudflare Workers |\n| `@pikku/uws-server` | uWebSockets.js (high perf) |\n| `@pikku/modelcontextprotocol` | MCP server |\n\n## Service Packages\n\n| Package | Provides |\n| ------------------------ | ---------------------------------------------------- |\n| `@pikku/jose` | JWT (sign/verify) via jose library |\n| `@pikku/schema-ajv` | Schema validation via AJV |\n| `@pikku/schema-cfworker` | Schema validation for Cloudflare |\n| `@pikku/pino` | Structured logging via Pino |\n| `@pikku/kysely` | Type-safe SQL via Kysely (PostgreSQL, SQLite, MySQL) |\n| `@pikku/redis` | Redis client |\n| `@pikku/queue-bullmq` | Job queues via BullMQ |\n| `@pikku/queue-pg-boss` | Job queues via PgBoss |\n| `@pikku/aws-services` | AWS SDK (SQS, DynamoDB, etc.) |\n", "pikku-concepts/SKILL.md": "---\nname: pikku-concepts\ndescription: >-\n Foundational guide to Pikku framework concepts. Use this skill when working with any Pikku\n codebase, starting a new Pikku project, or migrating a backend to Pikku. Covers the core mental\n model, function types, project structure, code generation, testing, and how Pikku maps to\n traditional backend patterns. TRIGGER when: user asks \"what is Pikku?\", starts a new Pikku\n project, migrates from Express/NestJS/Hono, or needs to understand how Pikku works. DO NOT\n TRIGGER when: user is doing a specific wiring task (use the specific skill instead, e.g.\n pikku-http, pikku-websocket).\ninstallGroups: [core]\n---\n\n# Pikku Framework Concepts\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nPikku is a TypeScript framework that separates business logic from transport mechanisms. You define a function once, then wire it to HTTP, WebSocket, queues, schedulers, MCP, CLI, or RPC — without the function knowing how it's being called.\n\nFor deep-dive on each topic, see the dedicated skills:\n\n- **Wiring**: `pikku-http`, `pikku-websocket`, `pikku-rpc`, `pikku-mcp`, `pikku-queue`, `pikku-cron`, `pikku-trigger`, `pikku-cli`, `pikku-ai-agent`, `pikku-workflow`\n- **Authorization**: `pikku-security` (authentication/sessions), `pikku-permissions` (permission checks, scopes), `pikku-middleware` (global/tag/route middleware)\n- **Infrastructure**: `pikku-services`, `pikku-config`\n- **Project introspection**: `pikku-info`\n\n## Core Mental Model\n\n```text\npikkuFunc (pure business logic)\n │\n ├── wireHTTP → Express, Fastify, Next.js, Lambda, Cloudflare...\n ├── wireChannel → WebSocket (real-time)\n ├── wireQueueWorker → BullMQ, PgBoss (async jobs)\n ├── wireScheduler → Cron (scheduled tasks)\n ├── wireMCPTool → Model Context Protocol (AI tools)\n ├── wireCLI → CLI commands\n ├── wireTrigger → Event-driven (Redis pub/sub, PG LISTEN/NOTIFY)\n ├── pikkuAIAgent → AI agents / chatbots\n ├── pikkuWorkflow → Multi-step durable workflows\n └── wire.rpc → Internal function-to-function calls\n```\n\nA `pikkuFunc` receives three things:\n\n1. **Services** — injected dependencies (logger, db, jwt, custom stores). See `pikku-services`.\n2. **Data** — input from any source (HTTP body/query/params, WS message, queue payload, CLI args)\n3. **Wire** — transport context (session, channel, rpc, mcp, http, queue)\n\nThe function never imports Express, never reads `req.body`, never touches `ws.send()`. It just works with typed data and services.\n\n## Concept Mapping: Generic Backend → Pikku\n\nControllers/routes → `pikkuFunc`; auth/sessions → `pikku-security`; authorization checks → `pikku-permissions`; request interception → `pikku-middleware`; DI → `pikku-services`; transports (HTTP/WS/queue/cron) → their `wire*` + skill. For the full Generic Backend → Pikku mapping table (with side-by-side code examples), read `references/concept-mapping.md`.\n\n## Functions\n\nThree main function types:\n\n```typescript\n// Requires authentication — receives session in wire context.\n// input/output are Zod schemas; the data + return types are inferred from them.\nconst updateTodo = pikkuFunc({\n input: UpdateTodoInput,\n output: TodoOutput,\n func: async (services, data, wire) => {\n const { session } = wire\n return services.todoStore.update(data.id, data)\n },\n})\n\n// No authentication required\nconst listTodos = pikkuSessionlessFunc({\n input: ListTodosInput,\n output: TodoListOutput,\n func: async (services, data) => {\n return { todos: services.todoStore.list(data.filters) }\n },\n})\n\n// No input or output (for scheduled tasks, lifecycle hooks)\nconst cleanup = pikkuVoidFunc(async (services) => {\n services.todoStore.cleanOldItems()\n})\n```\n\nServices can be destructured inline in the `func` signature (e.g. `async ({ logger, todoStore }, { title }) => ...`). Full config options:\n\n```typescript\npikkuFunc({\n // Identity and documentation\n title?: string, // Human-readable name\n description?: string, // What the function does\n version?: number, // Contract version (see pikku-versioning)\n override?: string, // Logical name override, so several exports share a versioned base\n tags?: string[], // For grouping and middleware targeting\n\n // Contract\n input?: ZodSchema, // Input validation schema\n output?: ZodSchema, // Output validation schema\n errors?: Array<typeof PikkuError>, // Errors this function may throw\n\n // Reachability\n expose?: boolean, // Allow external RPC calls (see pikku-rpc)\n remote?: boolean, // Allow remote RPC calls\n mcp?: boolean, // Expose as MCP tool (see pikku-mcp)\n readonly?: boolean, // Declares the function performs no writes\n deploy?: 'serverless' | 'server' | 'auto',\n\n // Authorization — see pikku-permissions\n auth?: boolean, // Override default auth requirement\n scopes?: ScopeId[], // AND-ed, checked before permissions; session required\n permissions?: PermissionGroup, // OR-ed pool\n permissionsInBody?: boolean, // Last resort; needs allow.permissionsInBody in config\n middleware?: PikkuMiddleware[], // See pikku-middleware\n\n // Agent tooling — see pikku-ai-agent\n approvalRequired?: boolean,\n approvalDescription?: (services, data) => Promise<string>,\n\n // Workflow step behavior — see pikku-workflow\n workflowQueued?: boolean, // Dispatch via queue instead of inline\n workflowRetries?: number,\n workflowTimeout?: string, // e.g. '30s', '5m'\n\n audit?: boolean | { durability?: 'best-effort' | 'transactional' },\n\n func: async (services, data, wire) => { ... },\n})\n```\n\n`scopes` is the one option `pikkuSessionlessFunc` does not accept, and the\nomission is deliberate: scopes are AND-ed and fail closed, so an anonymous\ncaller holds none and satisfies none — a sessionless function with scopes would\nreject every caller it exists to serve. Gate those with `permissions`, which\nreceive the optional session and may pass anonymous.\n\n**Generics XOR `input`/`output` — never both.** A function's data and return\ntypes come from *one* source: either the `input`/`output` schemas (preferred —\nthey double as runtime validation and OpenAPI) or type generics\n(`pikkuFunc<In, Out>({ ... })`). Passing both makes the two disagree and forces\n`as any` casts. Do not annotate the `func` return type inline either — let the\n`output` schema (or the generic) be the single source of truth for the type.\n\n```typescript\n// Correct — schema-based (no generics, no inline return type)\npikkuFunc({ input: MyInput, output: MyOutput, func: async (s, d) => { ... } })\n// Correct — generic-based (no input/output)\npikkuFunc<MyIn, MyOut>({ func: async (s, d) => { ... } })\n// WRONG — mixing the two\npikkuFunc<MyIn, MyOut>({ input: MyInput as any, func: async (s, d) => { ... } })\n```\n\n## Schemas (Validation)\n\nPikku uses Standard Schema — works with Zod, Valibot, ArkType:\n\n```typescript\nimport { z } from 'zod'\n\nconst CreateTodoInputSchema = z.object({\n title: z.string().min(1).max(200),\n priority: z.enum(['low', 'medium', 'high']).optional(),\n tags: z.array(z.string()).optional(),\n})\n```\n\nSchemas serve triple duty: runtime validation, TypeScript types, and OpenAPI documentation.\n\n## Server Bootstrap\n\nThere are two ways to start a Pikku app. Pick based on whether you need to own the HTTP server.\n\n**1. Let Pikku own the server (preferred when you don't need a specific runtime)**\n\n`pikku dev` and `pikku serve` create the config and singleton services, start the server, and shut it down cleanly. You write no bootstrap code at all — startup and shutdown work goes in lifecycle hooks:\n\n```typescript\n// src/lifecycle.ts\nimport { pikkuServerLifecycle } from '@pikku/core'\nimport type { SingletonServices } from '../types/application-types.js'\n\nexport const lifecycle = pikkuServerLifecycle<SingletonServices>({\n beforeStart: async ({ kysely }) => {\n await runMigrations(kysely)\n },\n afterStart: async ({ logger }) => {\n logger.info('accepting traffic')\n },\n beforeStop: async ({ queueService }) => {\n await queueService.drain()\n },\n})\n```\n\nExport exactly one `pikkuServerLifecycle` from anywhere in `srcDirectories` — the inspector finds it by the wrapper call. Every hook is optional and receives the already-created singleton services. See pikku-services for the ordering and the `afterStop` caveat.\n\n**2. Bootstrap it yourself (required for a specific runtime)**\n\nExpress, Fastify, uWS, Lambda, Cloudflare and Next.js need their own entrypoint, because Pikku is embedded in a server you own:\n\n```typescript\nimport '../../functions/.pikku/pikku-bootstrap.gen.js' // Generated — registers all wirings\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\n// Pick your runtime:\nconst server = new PikkuFastifyServer(\n config,\n singletonServices,\n createWireServices\n)\n// or: new PikkuExpressServer(config, singletonServices, createWireServices)\n// or: pikkuAWSLambdaHandler(singletonServices)\n// or: PikkuCloudflareHandler(singletonServices)\n// or: pikkuNextHandler(singletonServices)\n\nawait server.init()\nawait server.start()\n```\n\n**Lifecycle hooks do not run on this path** — only `pikku dev` and `pikku serve` invoke them. Do your startup work directly in the entrypoint instead.\n\n`pikku validate` warns when a project starts a server by hand *and* depends on no runtime adapter, since that combination means path 1 was available and unused. Silence it with `\"lint\": { \"customServerBootstrap\": \"off\" }` in `pikku.config.json`.\n\n## Code Generation\n\nRun `npx pikku all` to generate:\n\n- `pikku-types.gen.ts` — Typed function factories and wiring functions\n- `pikku-fetch.gen.ts` — Type-safe HTTP client\n- `pikku-websocket.gen.ts` — Type-safe WebSocket client\n- `pikku-bootstrap.gen.ts` — Runtime initialization (auto-imports all wirings)\n- `pikku-services.gen.ts` — Service factory types\n\nConfig lives in `pikku.config.json`:\n\n```json\n{\n \"tsconfig\": \"./tsconfig.json\",\n \"srcDirectories\": [\"src\"],\n \"outDir\": \".pikku\"\n}\n```\n\n## Project Structure Convention\n\n```text\nsrc/\n├── functions/ # Business logic (pikkuFunc definitions)\n│ ├── todos.functions.ts\n│ ├── auth.functions.ts\n│ └── scheduled.functions.ts\n├── wirings/ # Transport bindings\n│ ├── todos.http.ts\n│ ├── channel.wiring.ts\n│ ├── scheduler.wiring.ts\n│ └── queue.wiring.ts\n├── schemas.ts # Zod/Valibot schemas\n├── services.ts # Service factories (see pikku-services)\n├── lifecycle.ts # Server lifecycle hooks (pikku dev/serve only)\n├── middleware.ts # Middleware definitions (see pikku-security)\n├── permissions.ts # Permission definitions (see pikku-security)\n└── .pikku/ # Generated (gitignored)\n ├── pikku-types.gen.ts\n ├── pikku-fetch.gen.ts\n └── pikku-bootstrap.gen.ts\n```\n\n## Environment Variables\n\nNever use `process.env` inside Pikku functions. Use the `variables` service (see `pikku-config`):\n\n```typescript\nconst apiKey = services.variables.get('API_KEY')\n```\n\n`process.env` belongs in server bootstrap code (`start.ts`) only.\n\n## Secrets\n\n`secrets` is not part of a function's services. It is available only in\n`pikkuServices`, `pikkuWireServices`, addon service factories and middleware —\nread it there, give the value to a service, and have the function ask that\nservice. Reaching for it through a cast throws at runtime.\n\n## Testing\n\nFunctions are easily testable because they're pure:\n\n```typescript\nconst mockServices = {\n logger: new MockLogger(),\n todoStore: new MockTodoStore(),\n}\n\n// Call function directly — no HTTP, no framework\nconst result = await listTodos.func(mockServices, { userId: 'test' })\nexpect(result.todos).toHaveLength(3)\n```\n\n## Available Packages\n\nPikku ships runtime adapters (`@pikku/express-server`, `@pikku/fastify-server`, `@pikku/next`, `@pikku/aws-lambda`, `@pikku/cloudflare`, `@pikku/uws-server`, `@pikku/modelcontextprotocol`, ...) and service packages (`@pikku/jose`, `@pikku/schema-ajv`, `@pikku/pino`, `@pikku/kysely`, `@pikku/redis`, `@pikku/queue-bullmq`, `@pikku/queue-pg-boss`, ...). For the full list with use cases, read `references/packages.md`.\n\n## Key Differences from Traditional Frameworks\n\n1. **No decorators** — plain functions + explicit wiring, not `@Get()` or `@Injectable()`\n2. **No classes required** — everything is functions and objects\n3. **Transport is configuration, not code** — business logic doesn't know about HTTP/WS/etc.\n4. **One function, many transports** — same function can serve HTTP, WebSocket, queue, and MCP simultaneously\n5. **Generated type safety** — clients are auto-generated with full types, not manually maintained\n6. **Schema-first validation** — Standard Schema (Zod/Valibot) replaces class-validator decorators\n", "pikku-config/SKILL.md": "---\nname: pikku-config\ndescription: >-\n Use when managing secrets, environment variables, config, or OAuth2 credentials in a Pikku app.\n Covers defineSecret, defineVariable, defineCredential, and typed config access. TRIGGER when:\n code uses defineSecret/defineVariable/defineCredential, user asks about env vars, secrets,\n config, OAuth2, SecretValue/.reveal(), SecretCoercionError, or \"how do I access environment\n variables\". DO NOT TRIGGER when: user asks about API versioning/breaking changes (use\n pikku-versioning), service factories (use pikku-services), middleware (use pikku-middleware), or\n auth strategies and sessions (use pikku-security).\ninstallGroups: [core]\n---\n\n# Pikku Config, Secrets & OAuth2\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nManage secrets, variables, and OAuth2 credentials. Never use `process.env` in Pikku functions — use typed services instead.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their versions\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## Secrets & Variables\n\n### `defineSecret(config)`\n\nDeclare a secret with a Zod schema for type-safe access:\n\n```typescript\ndefineSecret({\n name: string, // Secret identifier\n schema: ZodSchema, // Shape and validation\n})\n```\n\n### `defineVariable(config)`\n\nDeclare a variable (non-sensitive config) with a Zod schema:\n\n```typescript\ndefineVariable({\n name: string,\n schema: ZodSchema,\n})\n```\n\n### Accessing Secrets\n\n`secrets` is **not available inside functions, AI agents, workflows, permissions\nor any wire** — it is removed from their services type and throws at runtime if\nreached through a cast. Read it where you wire the app and hand the value to a\nservice:\n\n`getSecret` returns a `SecretValue<T>`, not the bare value. It is nominal — not\nassignable to `string`, so every concretely-typed sink rejects it — it serializes\nto `[secret]` in logs and audits, and coercing it to a string (a template\nliteral, a concatenation) throws `SecretCoercionError`, because that is always a\nleak. `.reveal()` is the one way out, which makes every disclosure deliberate and\ngreppable. Call it at the point the value reaches the thing that needs it:\n\n```typescript\n// services.ts — allowed\nconst createSingletonServices = pikkuServices(async (config, { secrets }) => ({\n stripe: new StripeService((await secrets.getSecret('STRIPE_CONFIG')).reveal()),\n}))\n\n// functions/*.ts — ask the service, never the secret store\nexport const charge = pikkuFunc({\n func: async ({ stripe }, data) => stripe.charge(data.amount),\n})\n```\n\nAllowed: `pikkuServices`, `pikkuWireServices`, addon service factories,\nmiddleware. Everywhere else, the service you constructed is the interface.\n\n### Accessing Variables in Functions\n\n```typescript\n// Variables — plain-text configuration\nconst flags = await services.variables.getVariableJSON('VARIABLE_NAME')\n\n// Simple string access\nconst apiKey = services.variables.get('API_KEY')\n```\n\n### Local Development Services\n\n```typescript\nimport { LocalSecretService, LocalVariablesService } from '@pikku/core/services'\n\nconst createSingletonServices = pikkuServices(async (config) => ({\n secrets: new LocalSecretService(), // Reads from .env or local files\n variables: new LocalVariablesService(), // Reads from environment\n}))\n```\n\n### Usage Patterns\n\n```typescript\n// Declare secrets with typed schemas\ndefineSecret({\n name: 'STRIPE_CONFIG',\n schema: z.object({\n apiKey: z.string().startsWith('sk_'),\n webhookSecret: z.string(),\n }),\n})\n\n// In your services factory — fully typed\nconst config = (await secrets.getSecret('STRIPE_CONFIG')).reveal()\n// config.apiKey → string (autocompleted)\n// config.webhookSecret → string (autocompleted)\n\n// Declare variables\ndefineVariable({\n name: 'FEATURE_FLAGS',\n schema: z.object({\n darkMode: z.boolean(),\n maxUploadMB: z.number().default(10),\n }),\n})\n\n// Read it — typed and validated\nconst flags = await variables.getVariableJSON('FEATURE_FLAGS')\n// flags.darkMode → boolean\n// flags.maxUploadMB → number\n```\n\n## Credentials\n\n### `defineCredential(config)`\n\n```typescript\ndefineCredential({\n name: string, // Credential identifier\n displayName: string, // Human-readable name\n type: 'wire' | 'singleton', // Per-user ('wire') or platform-level ('singleton')\n schema: ZodSchema, // Shape of the stored credential\n oauth2?: { // Omit entirely for a plain API key\n appCredentialSecretId: string, // Secret holding { clientId, clientSecret }\n tokenSecretId: string, // Secret for token storage (auto-refreshed)\n authorizationUrl: string, // OAuth2 authorization endpoint\n tokenUrl: string, // OAuth2 token endpoint\n scopes: string[], // Required OAuth2 scopes\n },\n})\n```\n\n### Usage\n\n```typescript\n// Per-user API key — no oauth2 block\ndefineCredential({\n name: 'stripe',\n displayName: 'Stripe API Key',\n type: 'wire',\n schema: z.object({ apiKey: z.string() }),\n})\n\n// Platform-level OAuth (singleton)\ndefineCredential({\n name: 'slack',\n displayName: 'Slack',\n type: 'singleton',\n schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),\n oauth2: {\n appCredentialSecretId: 'SLACK_OAUTH_APP',\n tokenSecretId: 'SLACK_OAUTH_TOKENS',\n authorizationUrl: 'https://slack.com/oauth/v2/authorize',\n tokenUrl: 'https://slack.com/api/oauth.v2.access',\n scopes: ['chat:write', 'channels:read'],\n },\n})\n\n### Reading a Credential\n\nA declared credential is resolved per invocation through `wire.getCredential(name)`,\nso the natural place to read it is a wire service factory: build the client there\nonce and let functions ask the client, the same way they ask a service for a\nsecret-derived value. Tokens refresh automatically, so what arrives is already\nvalid.\n\n```typescript\nexport const createWireServices = pikkuWireServices(async (_services, wire) => {\n const cred = await wire.getCredential?.<{ accessToken: string }>('slack')\n if (!cred?.accessToken) {\n // Tells the caller which credential to connect, and where.\n throw new MissingCredentialError('slack', 'oauth2', '/credentials/slack/connect')\n }\n return { slack: new SlackClient(cred.accessToken) }\n})\n\n// functions/*.ts — ask the client, never the credential store\nexport const postMessage = pikkuFunc({\n func: async ({ slack }, { channel, text }) => slack.postMessage(channel, text),\n})\n```\n\nA `wire` credential resolves per user, so an unconnected user hits\n`MissingCredentialError` rather than silently acting as someone else; a\n`singleton` credential is platform-level and identical for every caller.\n\n## Key Rule\n\n**Never use `process.env` inside Pikku functions.** Use the `variables` or `secrets` service:\n\n```typescript\n// ❌ Wrong\nconst apiKey = process.env.API_KEY\n\n// ✅ Correct\nconst apiKey = services.variables.get('API_KEY')\n```\n\n`process.env` belongs only in server bootstrap code (`start.ts`). Under `pikku dev` / `pikku serve` there is no `start.ts` — startup work goes in a `pikkuServerLifecycle` export, and the hooks receive the singleton services, so read configuration through `variables` there too (see pikku-services).\n\n### Lint rules\n\n`pikku.config.json` can set the severity of individual checks:\n\n```json\n{\n \"lint\": {\n \"servicesNotDestructured\": \"error\",\n \"wiresNotDestructured\": \"error\",\n \"functionDynamicImport\": \"warn\",\n \"customServerBootstrap\": \"warn\"\n }\n}\n```\n\n`customServerBootstrap` is the one evaluated by `pikku validate` rather than codegen: it warns when the root `start`/`dev` script boots a server without `pikku dev` / `pikku serve` and no runtime adapter is installed. Set it to `\"off\"` to keep a hand-rolled entrypoint, or `\"error\"` to enforce the hooks.\n\n## Complete Example\n\n```typescript\n// schemas/config.ts\ndefineSecret({\n name: 'DATABASE_CONFIG',\n schema: z.object({\n connectionString: z.string().url(),\n maxPoolSize: z.number().default(10),\n }),\n})\n\ndefineVariable({\n name: 'APP_CONFIG',\n schema: z.object({\n appName: z.string(),\n maxUploadSizeMB: z.number().default(10),\n maintenanceMode: z.boolean().default(false),\n }),\n})\n\ndefineCredential({\n name: 'githubOAuth',\n displayName: 'GitHub OAuth',\n type: 'wire',\n schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),\n oauth2: {\n appCredentialSecretId: 'GITHUB_OAUTH_APP',\n tokenSecretId: 'GITHUB_OAUTH_TOKENS',\n authorizationUrl: 'https://github.com/login/oauth/authorize',\n tokenUrl: 'https://github.com/login/oauth/access_token',\n scopes: ['read:user', 'repo'],\n },\n})\n\n// functions/admin.functions.ts\nexport const getAppStatus = pikkuSessionlessFunc({\n title: 'Get App Status',\n func: async ({ variables }) => {\n const appConfig = await variables.getVariableJSON('APP_CONFIG')\n return {\n appName: appConfig.appName,\n maintenanceMode: appConfig.maintenanceMode,\n }\n },\n})\n```\n", "pikku-cron/SKILL.md": "---\nname: pikku-cron\ndescription: >-\n Use when adding scheduled tasks, recurring jobs, or cron-based automation to a Pikku app. Covers\n wireScheduler, cron expressions, scheduled task wire object, and scheduler middleware. TRIGGER\n when: code uses wireScheduler, user asks about cron, scheduled tasks, recurring jobs, or \"run\n every X minutes/hours\". DO NOT TRIGGER when: user asks about background jobs with retries (use\n pikku-queue) or event-driven triggers (use pikku-trigger).\ninstallGroups: [core]\n---\n\n# Pikku Cron/Scheduler Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to run on a schedule using cron expressions. Uses `pikkuVoidFunc` (no input/output).\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireScheduler(config)`\n\n```typescript\nimport { wireScheduler } from '@pikku/core/scheduler'\n\nwireScheduler({\n name: string, // Unique scheduler name\n schedule: string, // Cron expression\n func: PikkuVoidFunc, // Must be pikkuVoidFunc (no input/output)\n tags?: string[], // Targets tag middleware — see pikku-middleware\n middleware?: PikkuMiddleware[],\n})\n```\n\n### Wire Object (`wire.scheduledTask`)\n\nInside scheduled functions:\n\n```typescript\nwire.scheduledTask.name // Scheduler name\nwire.scheduledTask.schedule // Cron expression string\nwire.scheduledTask.executionTime // Date this execution was triggered\nwire.scheduledTask.skip(reason?) // Abort this execution — THROWS, never returns\n```\n\n**`skip()` aborts by throwing.** It reads like an early return but it is not:\nnothing after the call runs, so there is no need to `return` afterwards. The\nconsequence that bites is in middleware — a `try/catch` around `await next()`\nwill catch a skip and report it as a failure. If your middleware distinguishes\nsuccess from failure, let the skip pass through rather than logging it as an\nerror.\n\n### Cron Expression Reference\n\n```\n┌───────────── minute (0-59)\n│ ┌───────────── hour (0-23)\n│ │ ┌───────────── day of month (1-31)\n│ │ │ ┌───────────── month (1-12)\n│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 = Sunday)\n│ │ │ │ │\n* * * * *\n```\n\nCommon patterns:\n\n| Expression | Meaning |\n| ------------- | -------------------------- |\n| `*/5 * * * *` | Every 5 minutes |\n| `0 9 * * *` | Daily at 9:00 AM |\n| `0 9 * * 1` | Every Monday at 9:00 AM |\n| `0 0 1 * *` | First of month at midnight |\n| `0 */6 * * *` | Every 6 hours |\n| `30 2 * * 0` | Sundays at 2:30 AM |\n\n## Usage Patterns\n\n### Basic Scheduled Task\n\n```typescript\nconst dailySummary = pikkuVoidFunc({\n title: 'Daily Summary',\n func: async ({ db, emailService, logger }) => {\n logger.info('Generating daily summary')\n const stats = await db.getDailyStats()\n await emailService.sendSummary(stats)\n },\n})\n\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n})\n```\n\n### Using the Wire Object\n\n```typescript\nconst weeklyCleanup = pikkuVoidFunc({\n title: 'Weekly Cleanup',\n func: async ({ db, logger }, _input, wire) => {\n logger.info(`Running: ${wire.scheduledTask.name}`)\n logger.info(`Schedule: ${wire.scheduledTask.schedule}`)\n logger.info(`Execution time: ${wire.scheduledTask.executionTime}`)\n\n const staleCount = await db.countStaleTodos()\n if (staleCount === 0) {\n wire.scheduledTask.skip('No stale todos found') // throws — nothing below runs\n }\n\n await db.deleteCompletedTodos({ olderThan: '30d' })\n logger.info(`Cleaned ${staleCount} stale todos`)\n },\n})\n\nwireScheduler({\n name: 'weeklyCleanup',\n schedule: '0 0 * * 0',\n func: weeklyCleanup,\n})\n```\n\n### Scheduler Middleware\n\n```typescript\nconst schedulerMetrics = pikkuMiddleware(\n async ({ logger }, { scheduledTask }, next) => {\n const start = Date.now()\n logger.info(`Task started: ${scheduledTask.name}`)\n\n try {\n await next()\n logger.info(`Task completed: ${scheduledTask.name}`, {\n duration: Date.now() - start,\n })\n } catch (error) {\n logger.error(`Task failed: ${scheduledTask.name}`, {\n error: error.message,\n duration: Date.now() - start,\n })\n throw error\n }\n }\n)\n\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n middleware: [schedulerMetrics],\n})\n```\n\n## Complete Example\n\n```typescript\n// functions/scheduled.functions.ts\nexport const dailySummary = pikkuVoidFunc({\n title: 'Daily Summary',\n func: async ({ db, emailService, logger }) => {\n const stats = await db.getDailyStats()\n await emailService.sendSummary(stats)\n logger.info('Daily summary sent', { stats })\n },\n})\n\nexport const cleanupExpired = pikkuVoidFunc({\n title: 'Cleanup Expired',\n func: async ({ db, logger }, _input, wire) => {\n const count = await db.countExpiredSessions()\n if (count === 0) {\n wire.scheduledTask.skip('No expired sessions') // throws — nothing below runs\n }\n await db.deleteExpiredSessions()\n logger.info(`Cleaned ${count} expired sessions`)\n },\n})\n\nexport const syncInventory = pikkuVoidFunc({\n title: 'Sync Inventory',\n func: async ({ inventoryApi, db, logger }) => {\n const updates = await inventoryApi.getChanges()\n await db.applyInventoryUpdates(updates)\n logger.info(`Synced ${updates.length} inventory changes`)\n },\n})\n\n// wirings/scheduler.wiring.ts\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n})\nwireScheduler({\n name: 'cleanupExpired',\n schedule: '0 */6 * * *',\n func: cleanupExpired,\n})\nwireScheduler({\n name: 'syncInventory',\n schedule: '*/15 * * * *',\n func: syncInventory,\n})\n```\n", "pikku-deploy-azure/SKILL.md": "---\nname: pikku-deploy-azure\ndescription: >-\n Use when deploying a Pikku app to Azure Functions. Covers createAzureHandler for HTTP, storage\n queue and timer triggers, plus AzInvocationLogger and PikkuAZTimerRequest. TRIGGER when: user\n asks about Azure Functions, Azure deployment, or @pikku/azure-functions. DO NOT TRIGGER when:\n user asks about AWS Lambda (use pikku-deploy-lambda) or Cloudflare Workers (use\n pikku-deploy-cloudflare).\n---\n\n# Pikku Azure Functions Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/azure-functions` provides Azure Functions runtime adapters for Pikku.\n\n## Installation\n\n```bash\nyarn add @pikku/azure-functions @azure/functions\n```\n\n## API Reference\n\nExported from `@pikku/azure-functions`:\n\n- `createAzureHandler(factories, handlerTypes)` — the entry point. Returns\n `{ http?, queue?, timer? }` for the handler types you ask for.\n- `createAzureWorkerHandler(factories)` — `createAzureHandler(factories, ['fetch'])`.\n- `createAzureWebSocketHandler(factories)` — **a stub**: its `negotiate` always\n answers `501 WebSocket via Azure Web PubSub not yet implemented`. Channels do\n not work on Azure yet; do not plan a deployment around it.\n- `AzInvocationLogger` — the logger. Note the name: there is no\n `PikkuAzFunctionsLogger`.\n- `PikkuAZTimerRequest` — `new PikkuAZTimerRequest(context, data)`, a\n `PikkuRequest` carrying the data. The context argument is accepted and\n ignored.\n- `AzureQueueService`, `AzureDeploymentService`.\n\n`factories` is `{ createConfig, createSingletonServices, createPlatformServices? }`.\nServices are built from `process.env` and cached in module scope across\ninvocations of the same instance.\n\n## Usage Patterns\n\n### Registering handlers\n\n```typescript\nimport { app } from '@azure/functions'\nimport { createAzureHandler } from '@pikku/azure-functions'\nimport { createConfig, createSingletonServices } from './services.js'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst handlers = createAzureHandler(\n { createConfig, createSingletonServices },\n ['fetch', 'queue', 'scheduled']\n)\n\napp.http('api', {\n methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],\n route: '{*path}',\n handler: handlers.http as any,\n})\n\napp.storageQueue('queue', {\n queueName: 'my-queue',\n connection: 'AzureWebJobsStorage',\n handler: handlers.queue as any,\n})\n\napp.timer('scheduler', {\n schedule: '0 */5 * * * *',\n handler: handlers.timer as any,\n})\n```\n\nNote the key names: `handlerTypes` uses **`scheduled`**, but the handler it\nreturns is **`timer`**.\n\n### HTTP\n\nThe handler buffers the whole body, converts to a standard `Request`, and\nreturns the response body as **text** — a streaming or binary response is\nflattened. It takes no `RunHTTPWiringOptions`, so there is no `maxBodySize` or\n`respondWith404` here; Azure's own request limits are the bound. A thrown error\nis logged to `console.error` and whatever the response already holds is\nreturned.\n\n### Queue\n\nThe queue name comes from the message's own `queueName`, falling back to\n`context.triggerMetadata.queueTrigger` and then `'unknown'` — a name that does\nnot match a wired queue means the job has no handler. `attemptsMade` is read\nfrom `dequeueCount`, and `waitForCompletion` throws: Azure Storage Queues are\nfire-and-forget. A failing job throws out of the handler, so retries and the\npoison queue are governed by `host.json`, not by Pikku.\n\nProducer side, `AzureQueueService(connectionString?)` falls back to\n`AzureWebJobsStorage` and throws at construction if neither is set. Messages are\nbase64-encoded (Azure requires it), `delay` is milliseconds mapped to\n`visibilityTimeout` in whole seconds capped at 7 days, `supportsResults` is\n`false` and `getJob()` always throws. The queue name is remapped through\n`AZURE_QUEUE_NAME_<SCREAMING_SNAKE>` when that variable exists, otherwise used\nas-is.\n\n### Timer\n\nThe timer handler runs **every** scheduled task registered in the bundle,\nignoring both the `Timer` argument and each task's own cron expression. Unlike\nthe Lambda equivalent it does not catch per-task failures, so the first task\nthat throws aborts the ones after it — keep one schedule per function app, or\nguard the task bodies yourself.\n\n### Logging\n\n`new AzInvocationLogger(context)` forwards to the invocation context's\n`info`/`warn`/`error`/`debug`/`trace`. `setLevel()` is a **no-op**: every level\nis emitted and filtering has to be done in Azure's own logging configuration.\n", "pikku-deploy-cloudflare/SKILL.md": "---\nname: pikku-deploy-cloudflare\ndescription: >-\n Use when deploying a Pikku app to Cloudflare Workers. Covers HTTP fetch handler, scheduled\n tasks, and WebSocket via Durable Objects. TRIGGER when: code imports @pikku/cloudflare, user\n mentions Cloudflare Workers deployment, or worker entry uses ExportedHandler/wrangler.toml. DO\n NOT TRIGGER when: just defining functions/wirings without Cloudflare-specific code.\ninstallGroups: [fabric]\n---\n\n# Pikku Cloudflare Workers Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n```bash\nyarn add @pikku/cloudflare\n```\n\n## Worker Entry\n\n`@pikku/cloudflare` ships the handler factories the deploy codegen emits — use\nthem rather than hand-rolling an `ExportedHandler`. Each returns a\n`WorkerEntrypoint` class that sets services up on every invocation (cached after\nthe first) and adds an RPC-callable `runRpc(name, args)`:\n\n```typescript\nimport { createCloudflareHandler } from '@pikku/cloudflare'\nimport { createConfig, createSingletonServices } from './services.js'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nexport default createCloudflareHandler(\n { createConfig, createSingletonServices },\n ['fetch', 'scheduled']\n)\n```\n\n| Factory | For |\n| --- | --- |\n| `createCloudflareHandler(factories, handlerTypes)` | combined `fetch`/`queue`/`scheduled` |\n| `createCloudflareWorkerHandler(factories)` | HTTP / agent / RPC / workflow-orchestrator units |\n| `createCloudflareCronHandler(factories)` | cron units |\n| `createCloudflareQueueHandler(factories)` | queue-consumer and workflow-step units |\n| `createCloudflareMCPHandler(factories)` | MCP units (same HTTP transport) |\n| `createCloudflareWebSocketHandler(factories)` | channel units, delegating to the DO |\n\n`factories` is `{ createConfig, createSingletonServices, createPlatformServices? }`.\n\n## Service Setup\n\nCloudflare passes env bindings per-request, so services are built from `env`\nrather than at module load. `setupServices(env, factories)` is exported from\n`@pikku/cloudflare` and is what the factories call:\n\n```typescript\nimport { setupServices } from '@pikku/cloudflare'\n\nconst services = await setupServices(env, {\n createConfig,\n createSingletonServices,\n})\n```\n\n**Do not hand-roll this.** Beyond building `LocalVariablesService` /\n`LocalSecretService` and caching the result, it calls `setSingletonServices()` —\nand the core runners (`fetchData`, `runQueueJob`, `runScheduled`) resolve\nservices through that global slot, *not* through the value you were returned. A\nsetup function that only returns the services leaves every request throwing\n\"Singleton services not initialized\" as a CF `1101`. It also stashes the env via\n`setCloudflareEnv`, which `getCloudflareEnv()` reads for bindings.\n\n## HTTP\n\n`runFetch(request, websocketHibernationServer?, options?)`:\n\n- A `GET` with `Upgrade: websocket` is routed to the hibernation server. Without\n one passed in it answers **426**, so a channel worker that forgets the second\n argument fails every upgrade while plain HTTP keeps working.\n- `CF-Ray` becomes the traceId when present, so a Cloudflare trace and a Pikku\n trace line up without extra wiring.\n- `options.exposeErrors` defaults to **`false`** — error detail is withheld from\n responses unless you opt in.\n\n## Scheduled Tasks\n\n`runScheduled(controller)` matches registered tasks against\n`controller.cron` and **returns after the first match**. Two tasks sharing one\ncron expression means only one of them ever runs — give each its own expression,\nor invoke `runScheduledTask({ name })` per task yourself.\n\n## WebSocket (Durable Objects)\n\nThe ready-made DO class is exported; re-export it under the binding name and\npoint the worker at it:\n\n```typescript\nexport { PikkuWebSocketHibernationServer as WebSocketHibernationServer } from '@pikku/cloudflare'\nexport default createCloudflareWebSocketHandler({\n createConfig,\n createSingletonServices,\n})\n```\n\nSubclass `CloudflareWebSocketHibernationServer` only when you need something\n`getParams()` cannot express — it is abstract with one method returning\n`{ singletonServices, createWireServices? }`. The channel store\n(`CloudflareWebsocketStore` over the DO's own storage), the event hub and the\nchannel handler factory are all built by the base class; do not supply them.\n\nThe router looks up the DO through the **`WEBSOCKET_HIBERNATION_SERVER`**\nbinding and answers `503` naming it if the binding is missing, so declare it in\n`wrangler.toml` under exactly that name.\n\nA throw during `onConnect` closes the socket with `1008` and answers `403\nForbidden` with a deliberately generic body — an auth denial and a genuine fault\nlook identical to the client. The real reason is on the logger, so read the\nworker logs rather than the status code.\n", "pikku-deploy-express/SKILL.md": "---\nname: pikku-deploy-express\ndescription: >-\n Use when deploying a Pikku app with Express. Covers PikkuExpressServer standalone and\n pikkuExpressMiddleware for existing Express apps. TRIGGER when: code imports @pikku/express or\n @pikku/express-middleware, user mentions Express deployment, or start.ts creates a\n PikkuExpressServer. DO NOT TRIGGER when: just defining functions/wirings without\n Express-specific code.\n---\n\n# Pikku Express Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## Standalone Server\n\n```bash\nyarn add @pikku/express\n```\n\n```typescript\nimport { PikkuExpressServer } from '@pikku/express'\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst appServer = new PikkuExpressServer(\n { ...config, port: 4002, hostname: 'localhost' },\n singletonServices.logger\n)\nappServer.enableExitOnSigInt()\nawait appServer.init()\nawait appServer.start()\n```\n\n**Constructor:** `new PikkuExpressServer(config, logger)`\n\n**Config extends CoreConfig with:**\n\n- `port: number`\n- `hostname: string`\n- `healthCheckPath?: string`\n- `limits?: Partial<Record<string, string>>`\n- `content?: LocalContentConfig` (for static assets / file uploads)\n\n**Methods:**\n\n- `init(httpOptions?: RunHTTPWiringOptions): Promise<void>` — installs the body parsers, the cookie parser and the Pikku middleware\n- `start(): Promise<void>` — Start listening\n- `stop(): Promise<void>` — Graceful shutdown; throws if the server was never started\n- `enableExitOnSigInt(): Promise<void>` — SIGINT handler: stops the singleton services, then the server, then exits 0\n- `enableCors(options): void` — Enable CORS\n- `enableStaticAssets(): void` — serve `content.localFileUploadPath` under `content.assetUrlPrefix`\n- `enableReaper(): void` — a `PUT /reaper/*path` upload sink for local development, path-traversal checked and bounded by `content.sizeLimit` (default `1mb`)\n- `getHttpServer(): Server` — the underlying `http.Server`, e.g. to attach a WebSocket server; throws before `start()`\n\n`enableStaticAssets` and `enableReaper` both throw when `content` is unset.\n\n**Property:** `app: Express` — Direct access to Express instance for custom middleware.\n\n### Ordering, and what `init` installs for you\n\nThe health check is registered in the **constructor**, so it answers before any\nmiddleware you add and cannot be wrapped in auth. It defaults to\n`/health-check`; override with `healthCheckPath`.\n\nEverything else is installed by `init()`: `express.json`, `express.text` (for\n`text/xml`), `express.urlencoded`, `cookie-parser`, then the Pikku middleware.\nCall `enableCors` **before** `init` if you want CORS applied to Pikku's routes.\n\nExpress buffers the body before Pikku sees it, so the parser limit is the only\nplace an oversized request can actually be stopped. `httpOptions.maxBodySize`\ntherefore feeds those parser limits, with an explicit `config.limits` entry\n(`json` / `xml` / `urlencoded`) still winning. Everything defaults to `1mb`.\n\n`init` passes `logRoutes: true` and `loadSchemas: true` by default; your\n`httpOptions` spread over them, so you can turn either off.\n\n## Middleware (existing Express app)\n\n```bash\nyarn add @pikku/express-middleware\n```\n\n```typescript\nimport express from 'express'\nimport { pikkuExpressMiddleware } from '@pikku/express-middleware'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst app = express()\napp.use(express.json())\napp.use(cookieParser())\napp.use(\n pikkuExpressMiddleware({\n logger: singletonServices.logger,\n logRoutes: true,\n loadSchemas: true,\n // plus any RunHTTPWiringOptions: maxBodySize, respondWith404, coerceDataFromSchema\n })\n)\n```\n\nOptions beyond `logger` are all optional: `logRoutes` logs the wiring table once\nat startup, `loadSchemas` compiles every schema up front, and the rest are\n`RunHTTPWiringOptions` passed through per request.\n\nOn your own app **you** own the parser stack — the middleware reads\n`req.body`, so a body parser and `cookie-parser` must be registered before it,\nand `maxBodySize` alone will not stop an oversized request that your parser\nalready accepted. Unmatched requests fall through to `next()` (unless\n`respondWith404` is set), so Pikku's routes coexist with your existing ones;\na streaming response is the exception and does not call `next()`.\n", "pikku-deploy-fastify/SKILL.md": "---\nname: pikku-deploy-fastify\ndescription: >-\n Use when deploying a Pikku app with Fastify. Covers PikkuFastifyServer standalone and\n pikkuFastifyPlugin for existing Fastify apps. TRIGGER when: code imports @pikku/fastify or\n @pikku/fastify-plugin, user mentions Fastify deployment, or start.ts creates a\n PikkuFastifyServer. DO NOT TRIGGER when: just defining functions/wirings without\n Fastify-specific code.\n---\n\n# Pikku Fastify Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## Standalone Server\n\n```bash\nyarn add @pikku/fastify\n```\n\n```typescript\nimport { PikkuFastifyServer } from '@pikku/fastify'\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst appServer = new PikkuFastifyServer(\n { ...config, hostname: 'localhost', port: 4002 },\n singletonServices.logger\n)\nappServer.enableExitOnSigInt()\nawait appServer.init()\nawait appServer.start()\n```\n\n**Constructor:** `new PikkuFastifyServer(config, logger)`\n\n**Config extends CoreConfig with:** `port`, `hostname`, `healthCheckPath?`\n\n**Methods:** `init(httpOptions?: RunHTTPWiringOptions)`, `start()`, `stop()`, `enableExitOnSigInt()`\n\n**Property:** `app: FastifyInstance` — Direct access to Fastify instance.\n\n`enableCors` exists on the class but **throws `Method not implemented.`** — unlike\nthe Express server. Register `@fastify/cors` on `app` yourself before `init()`.\n\nUnlike the Express server, the health check is registered by `init()`, not the\nconstructor, so nothing answers before `init` runs. `init` also passes\n`logRoutes: true` and `loadSchemas: true`, which your `httpOptions` can override.\nThe Fastify instance is constructed with no options; reach for the plugin package\nif you need `Fastify({ … })` of your own.\n\n## Plugin (existing Fastify app)\n\n```bash\nyarn add @pikku/fastify-plugin\n```\n\n```typescript\nimport Fastify from 'fastify'\nimport pikkuFastifyPlugin from '@pikku/fastify-plugin'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst app = Fastify()\napp.register(pikkuFastifyPlugin, {\n pikku: {\n logger: singletonServices.logger,\n logRoutes: true,\n loadSchemas: true,\n // plus any RunHTTPWiringOptions: maxBodySize, respondWith404, …\n },\n})\n```\n\nEvery option other than `logger` is optional, and the rest of the `pikku` object\nis `RunHTTPWiringOptions` passed straight through.\n\nThe plugin registers a catch-all `fastify.all('/*')`, so mount it on a\n[Fastify prefix](https://fastify.dev/docs/latest/Reference/Plugins/) if the app\nhas routes of its own to keep.\n\nFastify buffers the body itself, so its `bodyLimit` is where an oversized request\nis stopped. `maxBodySize` sets it — and left unset, Fastify's stricter 1MB default\nstands rather than being loosened to Pikku's 10MB fallback.\n", "pikku-deploy-lambda/SKILL.md": "---\nname: pikku-deploy-lambda\ndescription: >-\n Use when deploying a Pikku app to AWS Lambda. Covers HTTP handlers, scheduled tasks, SQS queue\n workers, WebSocket via API Gateway, and cold start caching. TRIGGER when: code imports\n @pikku/lambda, user mentions Lambda/serverless/AWS deployment, or handler files export\n Lambda-typed functions. DO NOT TRIGGER when: just defining functions/wirings without\n Lambda-specific code.\n---\n\n# Pikku AWS Lambda Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n```bash\nyarn add @pikku/lambda\n```\n\n## Cold Start Pattern\n\nCache singleton services across Lambda invocations:\n\n```typescript\n// cold-start.ts\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nlet singletonServices: SingletonServices | undefined\n\nexport const coldStart = async () => {\n if (!singletonServices) {\n const config = await createConfig()\n singletonServices = await createSingletonServices(config)\n }\n return singletonServices\n}\n```\n\nIf the deploy codegen generated your handlers, this caching is already done for\nyou by the factories in `@pikku/lambda` — `createLambdaHandler(factories,\nhandlerTypes)`, `createLambdaWorkerHandler(factories)` and\n`createLambdaWebSocketHandler(factories)`. They build `variables`/`secrets` from\n`process.env`, cache the singleton services in module scope, and return the\nnamed exports (`handler`, `queue`, `scheduled`, or `connect`/`disconnect`/\n`default`) that `serverless.yml` references. Hand-written handlers are for cases\nthe codegen does not cover.\n\n## HTTP Handler\n\nPick the entry point that matches the API Gateway payload version — they take\ndifferent event types and are not interchangeable:\n\n```typescript\nimport type { APIGatewayEvent } from 'aws-lambda'\nimport { runFetch } from '@pikku/lambda/http' // REST API / payload v1\n\nexport const httpRoute = async (event: APIGatewayEvent) => {\n await coldStart()\n return await runFetch(event)\n}\n```\n\n```typescript\nimport type { APIGatewayProxyEventV2 } from 'aws-lambda'\nimport { runFetchV2 } from '@pikku/lambda/http' // HTTP API / payload v2\n\nexport const httpRoute = async (event: APIGatewayProxyEventV2) => {\n await coldStart()\n return await runFetchV2(event)\n}\n```\n\nBoth answer `OPTIONS` themselves before Pikku's wirings run, so a preflight\nnever reaches your middleware. Only `runFetchV2` echoes the request `Origin`\ninto `Access-Control-Allow-Origin`; `runFetch` sets allowed headers and methods\nbut **no origin header at all**, so v1 preflights fail in the browser unless\nAPI Gateway or a CloudFront layer adds one.\n\nThey also differ on failure: `runFetchV2` logs and returns a JSON `500`, while\n`runFetch` swallows the error and returns whatever status the response already\ncarried.\n\nNeither takes `RunHTTPWiringOptions` — there is no `maxBodySize` or\n`respondWith404` knob here; API Gateway's own payload limit is the bound.\n\n## Scheduled Tasks\n\n```typescript\nimport type { ScheduledHandler } from 'aws-lambda'\nimport { runLambdaScheduled } from '@pikku/lambda/scheduled'\n\nexport const scheduled: ScheduledHandler = async (event) => {\n await coldStart()\n await runLambdaScheduled(event)\n}\n```\n\n`runLambdaScheduled` runs **every** scheduled task registered in the bundle,\neach with its own `cron-<uuid>` traceId, and logs rather than rethrows a task\nfailure — so one bad task cannot fail the invocation or stop the others. The\nevent itself is ignored; which tasks run is decided by what the unit bundled,\nnot by which EventBridge rule fired.\n\nReach for `runScheduledTask({ name })` from `@pikku/core/scheduler` directly\nonly when one Lambda genuinely bundles several tasks that must fire on separate\nschedules.\n\n## SQS Queue Worker\n\n```typescript\nimport type { SQSHandler } from 'aws-lambda'\nimport { runSQSQueueWorker } from '@pikku/lambda/queue'\n\nexport const mySQSWorker: SQSHandler = async (event) => {\n const { logger } = await coldStart()\n return runSQSQueueWorker(logger, event)\n}\n```\n\nThe worker returns an `SQSBatchResponse` listing the failed messages in\n`batchItemFailures`, which SQS only honours when the event source mapping has\n**`ReportBatchItemFailures`** enabled. Without it the whole batch is retried\nwhen any one message fails, so successfully processed jobs run twice.\n\nRecords are processed in parallel, and the queue name is taken from the last\nsegment of `eventSourceARN` — it must match the name the worker was wired under.\nA `QueueJobDiscardedError` counts as success (no retry); anything else is\nreported as a failed item.\n\n`waitForCompletion` throws on an SQS job: the transport is fire-and-forget.\n\nOn the producer side, `SQSQueueService` resolves each queue URL from the\nconstructor's `queueUrlMap` first, then from\n`SQS_QUEUE_URL_<SCREAMING_SNAKE_NAME>`, and throws naming the missing variable\nif neither has it. `supportsResults` is `false` and `getJob()` always throws —\nuse BullMQ or PgBoss if you need results. `delay` is milliseconds, rounded up to\nwhole seconds and capped at SQS's 900s ceiling.\n\n## WebSocket (API Gateway v2)\n\n```typescript\nimport {\n connectWebsocket,\n disconnectWebsocket,\n processWebsocketMessage,\n LambdaEventHubService,\n} from '@pikku/lambda/websocket'\n\nconst params = async (event) => {\n const { channelStore } = await coldStart()\n return { channelStore }\n}\n\nexport const connectHandler = async (event) =>\n await connectWebsocket(event, await params(event))\n\nexport const disconnectHandler = async (event) =>\n await disconnectWebsocket(event, await params(event))\n\nexport const defaultHandler = async (event) =>\n await processWebsocketMessage(event, await params(event))\n```\n\nAll three take the same `{ channelStore }` and **return a complete\n`APIGatewayProxyResult`** — return it. Discarding `connectWebsocket`'s result\nand answering a hardcoded `200` accepts every connection, including the ones\nyour channel's auth rejected.\n\n`channelStore` (e.g. `PgChannelStore`) must be a real shared store: each route\nis a separate invocation, so nothing survives in memory between `$connect` and\n`$default`.\n\n`LambdaEventHubService` handles cross-connection messaging and takes\n`(logger, event, channelStore, eventHubStore)` — the `event` is needed to derive\nthe API Gateway Management endpoint, so it is constructed per invocation, not\nonce at cold start. It also needs an `EventHubStore` alongside the channel\nstore.\n\nTwo behaviours to design around: **binary payloads throw** (`Binary data is not\nsupported on serverless lambdas`), and any `PostToConnection` failure removes\nthe connection from the channel store — a transient error drops a live client,\nnot just a stale one.\n", "pikku-deploy-nextjs/SKILL.md": "---\nname: pikku-deploy-nextjs\ndescription: >-\n Use when deploying a Pikku app with Next.js. Covers API route handlers, server-side data\n fetching, and RPC calls from Server Components. TRIGGER when: code imports @pikku/next, user\n mentions Next.js integration, or app/api route files use pikkuAPIRequest. DO NOT TRIGGER when:\n just defining functions/wirings without Next.js-specific code.\n---\n\n# Pikku Next.js Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n```bash\nyarn add @pikku/next\n```\n\n## API Route Handler\n\nThe CLI generates a typed wrapper. Use it in a catch-all route:\n\n```typescript\n// app/api/[...route]/route.ts\nimport { pikkuAPIRequest } from '@/pikku-nextjs.gen.js'\n\nexport const GET = pikkuAPIRequest\nexport const POST = pikkuAPIRequest\nexport const PUT = pikkuAPIRequest\nexport const PATCH = pikkuAPIRequest\nexport const DELETE = pikkuAPIRequest\n```\n\n`pikkuAPIRequest` strips a leading `/api` from the pathname before routing, so\nwirings are declared as `/todos`, not `/api/todos`, even though the route file\nlives under `app/api`. Turn that off with `removeAPIPrefix(false)` from the same\ngenerated file if your wirings really do carry the prefix.\n\nIt takes `(req, context)` to match Next's handler signature but ignores the\ncontext — Pikku routes from the URL, so the catch-all segment name is yours to\nchoose. It also passes no `RunHTTPWiringOptions`: to set `maxBodySize` or\n`respondWith404` you need your own handler over `new PikkuNextJS(...)` calling\n`apiRequest(req, options)`.\n\n## Server-Side Data Fetching\n\nUse the generated `pikku()` helper in Server Components or Server Actions:\n\n```typescript\nimport { pikku } from '@/pikku-nextjs.gen.js'\n\nconst { get, post, patch, del, rpc, staticGet, staticPost, staticRPC } = pikku()\n\n// Dynamic (reads headers/cookies — requires request context)\nconst todos = await get('/todos')\nconst created = await post('/todos', { title: 'Buy milk' })\n\n// Static (no request context — suitable for precompile/ISR)\nconst config = await staticGet('/config')\n\n// RPC calls\nconst result = await rpc('calculateTax', { amount: 100, region: 'US' })\n```\n\n**Dynamic vs Static:**\n\n- `get`, `post`, `patch`, `del`, `rpc` — read `next/headers` cookies and headers,\n so they force the component dynamic\n- `staticGet`, `staticPost`, `staticRPC` — no request context, safe for\n precompile/ISR\n\nThe static variants pass `skipUserSession: true`, so a wiring that expects a\nsession sees none. That is the real difference — not just where they can run.\nThere is no `staticPatch` or `staticDel`; a mutation at build time is not a\nthing the generated client offers.\n\nBoth paths run with `bubbleErrors: true`, so a failing wiring **throws** in your\nServer Component rather than resolving to an error status. Wrap the call, or let\nthe Next.js error boundary take it.\n\n## How It Works\n\n`PikkuNextJS` lazy-initializes on first request:\n\n```typescript\nimport { PikkuNextJS } from '@pikku/next'\n\nconst pikku = new PikkuNextJS(createConfig, createSingletonServices)\n```\n\n**Constructor:** `new PikkuNextJS(createConfig | undefined, createSingletonServices)`\n\nBoth arguments are positional and `createConfig` is only optional in the sense\nthat passing `undefined` substitutes an empty config —\n`createSingletonServices` is required.\n\nInitialization is memoized on a promise, so concurrent first requests share one\nsetup; a failed setup clears the promise, so the next request retries rather\nthan caching the failure forever.\n\nThe generated `pikku-nextjs.gen.ts` wraps this with full type safety from your\nroute definitions.\n\n## Related exports\n\n- **`PikkuNextJSWorkerRPC({ fetcher })`** — same surface as `PikkuNextJS`, but\n every call is dispatched through a `Fetcher` (a Cloudflare service binding, a\n local HTTP client, a fabric dispatcher) instead of loading function code\n in-process. Use it to keep functions out of the SSR bundle. A non-2xx\n response throws with the status and body text.\n- **`toNextJsAuthHandler(auth)`** — wraps a better-auth instance or handler\n function for an auth route. The three-argument form\n `(pikkuAuthFactory, createConfig, createSingletonServices)` resolves a Pikku\n better-auth factory lazily; it throws if you pass `createConfig` without\n `createSingletonServices`. `nextCookies` is re-exported alongside it.\n", "pikku-deploy-uws/SKILL.md": "---\nname: pikku-deploy-uws\ndescription: >-\n Use when deploying a Pikku app with uWebSockets.js. Covers PikkuUWSServer with built-in HTTP and\n WebSocket support, and pikkuWebsocketHandler for standalone ws library. TRIGGER when: code\n imports @pikku/uws or @pikku/ws, user mentions uWebSockets or high-performance server, or\n start.ts creates a PikkuUWSServer. DO NOT TRIGGER when: just defining functions/wirings without\n uWS-specific code.\n---\n\n# Pikku uWebSockets.js Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nHighest-throughput option among Pikku's runtimes. Handles both HTTP and WebSocket automatically.\n\n```bash\nyarn add @pikku/uws\n```\n\n```typescript\nimport { PikkuUWSServer } from '@pikku/uws'\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst appServer = new PikkuUWSServer(\n { ...config, hostname: 'localhost', port: 4002 },\n singletonServices.logger\n)\nappServer.enableExitOnSigInt()\nawait appServer.init()\nawait appServer.start()\n```\n\n**Constructor:** `new PikkuUWSServer(config, logger)`\n\n**Config extends CoreConfig with:** `port`, `hostname`, `healthCheckPath?`\n\n**Methods:** `init(httpOptions?: RunHTTPWiringOptions)`, `start()`, `stop()`, `enableExitOnSigInt()`\n\n**Property:** `app: uWS.App` — Direct access to uWebSockets app instance.\n\n### What the server does and does not give you\n\n`init()` registers three things in order: the health check (`healthCheckPath`,\ndefault `/health-check`), a catch-all `app.any('/*')` HTTP handler, and a\ncatch-all `app.ws('/*')` websocket handler. Nothing is registered by the\nconstructor, so nothing answers before `init` runs.\n\n**There is no `enableCors`, no static assets and no `content` support** — unlike\nthe Express server. The class is explicitly a prototyping convenience; for\nanything that needs extra handlers, use `@pikku/uws-handler` directly and treat\n`pikku-uws-server.ts` as the template (that is what its own JSDoc says).\n\n`httpOptions` reaches the HTTP handler only. The websocket handler is\nconstructed with a fixed `{ logger, logRoutes: true }`, so per-request options\ndo not apply to the upgrade path. `loadSchemas` is also never passed by the\nserver, so schemas compile lazily on first use rather than at startup — pass\n`loadSchemas: true` in `httpOptions` if you want the startup cost paid up front.\n\n`stop()` closes the listen socket and then waits a fixed 2 seconds for\nconnections to drain. Called before `start()`, it throws a bare **string**, not\nan `Error`, so `catch (e) { e.message }` reads `undefined`.\n\n### Body limits\n\nuWS hands over raw chunks with no limit of its own, so the handler counts the\nbytes itself. A request over `maxBodySize` (default `DEFAULT_MAX_BODY_SIZE`) is\nanswered `413` with a `PayloadTooLargeError` body, and the chunks are dropped\nrather than concatenated — an oversized request never accumulates in memory. A\n`content-length` header that already exceeds the limit short-circuits before any\ndata arrives.\n\n### Handlers directly (own uWS app)\n\n```typescript\nimport { pikkuHTTPHandler, pikkuWebsocketHandler } from '@pikku/uws-handler'\n\napp.any('/*', pikkuHTTPHandler({ logger, logRoutes: true, loadSchemas: true }))\napp.ws('/*', pikkuWebsocketHandler({ logger, logRoutes: true }))\n```\n\nBoth take `{ logger, logRoutes?, loadSchemas? } & RunHTTPWiringOptions`.\n\n## WebSocket Standalone (ws library)\n\nFor WebSocket-only servers using the `ws` library:\n\n```bash\nyarn add @pikku/ws\n```\n\n```typescript\nimport { pikkuWebsocketHandler } from '@pikku/ws'\nimport { stopSingletonServices } from '@pikku/core'\nimport { Server } from 'http'\nimport { WebSocketServer } from 'ws'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst server = new Server()\nconst wss = new WebSocketServer({ noServer: true })\n\npikkuWebsocketHandler({\n server,\n wss,\n logger: singletonServices.logger,\n})\n\nserver.listen(4002, 'localhost', () => {\n console.log('Server running at http://localhost:4002/')\n})\n\nprocess.on('SIGINT', async () => {\n await stopSingletonServices()\n wss.close()\n server.close()\n process.exit(0)\n})\n```\n\n`pikkuWebsocketHandler` takes `{ server, wss, logger, logRoutes?, loadSchemas? }`\nplus `RunHTTPWiringOptions`, and there is no server class in `@pikku/ws` — the\nhandler attaches to a `Server` you own.\n\n`noServer: true` is required, not stylistic: the handler listens for the HTTP\nserver's own `upgrade` event, opens the channel (running middleware and auth\nfirst), and only then calls `wss.handleUpgrade`. A `WebSocketServer` bound to\nthe server would take the socket before any of that ran. An upgrade the channel\nrejects gets the socket destroyed, and an auth failure is written as a real HTTP\nresponse on the raw socket rather than a silent drop.\n", "pikku-deps/SKILL.md": "---\nname: pikku-deps\ndescription: >-\n Use for the Pikku dependency security audit: the `pikku audit` CLI command, the\n `.pikku/audit.json` artifact, the `SecurityAuditReport` type in @pikku/core, and the console\n Security screen (getSecurityAudit / runSecurityAudit / updateDependency + SecurityAuditView).\n TRIGGER when: user asks about `pikku audit`, dependency vulnerabilities/advisories, outdated\n dependencies, the Security screen/page in the console, updating a vulnerable dependency, or\n reading/rendering audit.json. DO NOT TRIGGER when: user asks about authentication/sessions/JWT\n (use pikku-security), permissions (use pikku-permissions), or secrets/env vars (use\n pikku-config).\ninstallGroups: [core]\n---\n\n# Pikku Dependency Audit\n\n## Agent Operating Procedure\n\n1. The audit is a generated artifact, not live state. `pikku audit` writes the\n normalised report to `.pikku/audit.json` (config `outDir`), so it rides the\n same meta pipeline as every other codegen output — uploaded on deploy,\n readable by the console addon and any tooling. Read it via\n `metaService.readFile('audit.json')`, never by shelling out to the package\n manager from a function.\n2. One source of truth for the shape: `SecurityAuditReport` (and\n `SecurityAuditIssue` / `SecurityAuditUpdate` / `SecurityAuditSummary` +\n `SecuritySeverity` / `SecurityUpdateLevel`) are exported from **@pikku/core**.\n The CLI writes it, the addon reads it, the UI renders it — never redeclare\n the type at a call site.\n3. Validate with `pikku all --tsc` after changes — it type-checks and **fails on\n type errors**, like any real build gate. Separately, `pikku audit` never fails\n a build: advisories are informational, and a missing/failed audit yields an\n empty-but-valid report.\n\n## The `pikku audit` command\n\n- `pikku audit` — reports **security advisories** only.\n- `pikku audit --outdated` — also reports **available dependency updates**.\n- Package-manager detection is by **lockfile**, walking up to 12 levels to the\n workspace root, checking in this order: `bun.lock`/`bun.lockb`,\n `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`. A project with several\n lockfiles resolves as bun. Only **bun** runs a real audit (`bun audit --json` +\n `bun outdated`, normalised into one `SecurityAuditReport` with per-severity /\n per-update-level counts). Other PMs are detected but **stubbed** with a `note`\n field until their shapes are normalised — issues/updates come back empty.\n- `bun audit` exits non-zero when it *finds* advisories but still writes the\n payload to stdout, so a non-zero exit **with output** is data. A non-zero exit\n with **no** output — or a launch failure, timeout, or a blown 32MB buffer —\n throws, precisely so a failed run can't masquerade as \"0 advisories\".\n\n## Console integration (@pikku/addon-console)\n\nThree RPCs, all reading/writing the same artifact via the meta service. Shared\nspawn/read helpers live in `lib/audit-exec.ts` (`readAuditReport`,\n`runPikkuAudit`, `spawnProcess`, `findBin`), alongside `lib/find-project-root.ts`\nand `lib/resolve-package-manager.ts` (`resolvePackageManager`, `installArgs`,\n`execPrefix`) — reuse them, don't re-implement. `resolvePackageManager` reads\npackage.json's corepack `packageManager` field first and only falls back to\nlockfiles, because that field states intent before a lockfile exists and a\nproject can carry a stale one from another tool. Guessing wrong is not a soft\nfailure: the spawn dies with `Executable not found in $PATH`.\nLike every console RPC these require an **authenticated session** (the console\nis admin-only), so the host must have Better Auth wired — see `pikku-better-auth`.\n\n- `getSecurityAudit` — reads `.pikku/audit.json`, returns the report (or `null`).\n- `runSecurityAudit` — runs `pikku audit --outdated` server-side (regenerates the\n artifact) then returns the fresh report. Same shape as the Run Tests action.\n- `updateDependency({ package, version })` — bumps the package in `package.json`\n (preserving the `^`/`~` range prefix), runs `bun install`, re-audits, and\n returns the fresh report. Throws if the package is not a direct dependency.\n NOTE: `bun install` must be scoped to a standalone project — do not run it\n inside a yarn/bun monorepo member (it resolves the whole workspace).\n\n## Console UI (@pikku/console)\n\n- `SecurityPage` — the page: **Run audit** button (`lead`) + responsive\n `ShellHeader` (structured `search` + `selection` for the Issues/Dependencies\n lens; never cram raw controls into the non-collapsing `filters`/`view` escape\n hatch). Empty state until an audit has run.\n- `SecurityAuditView` — exported presentational component. Two lenses\n (Issues grouped by severity; Dependencies table). Each finding row carries its\n actions **right-aligned in the row header** (`Accordion.Control` sibling, so a\n click acts instead of toggling): \"View advisory\" + a per-finding\n **remediation slot**.\n- `renderRemediation({ pkg, version, issue })` — the extension seam. OSS default\n is `UpdateDependencyButton` (the free bump + `bun install`). Downstream\n consoles (Fabric) pass their own sandbox-verified action here — replace the\n action, keep the view.\n- Hooks: `useSecurityAudit` (read), `useRunSecurityAudit` (run),\n `useUpdateDependency` (bump). All are `useMutation`/`useQuery` — surface\n `mutation.error`, never hand-roll loading/error state or swallow the error.\n\n## Report shape (SecurityAuditReport)\n\n```ts\n{\n schemaVersion: number\n tool: string // e.g. 'bun'\n generatedAt: string // ISO timestamp\n note?: string // set when the audit could NOT run (unsupported PM);\n // render ONLY the note — never a reassuring \"no vulnerabilities\"\n summary: {\n totalIssues, critical, high, moderate, low: number // no `info` bucket\n totalUpdates, major, minor, patch: number\n }\n issues: SecurityAuditIssue[] // package, severity, title, advisoryId, url,\n // vulnerableVersions, cwe[], cvssScore, recommendedVersion\n updates: SecurityAuditUpdate[] // package, current, latest, level (major|minor|patch|unknown)\n}\n```\n\n`severity` is one of `critical | high | moderate | low | info`, but `summary`\nhas no `info` count — an informational advisory raises `totalIssues` without\nlanding in a severity bucket, so don't sum the four to get the total. On an\nissue, `url`, `cvssScore` and `recommendedVersion` are always present and\n**nullable** rather than optional: check for `null`, not `undefined`.\n\nWhen `note` is present the audit did not run — show only the note (an \"Audit not\nrun\" state), never the \"no known vulnerabilities / up to date\" copy.\n", "pikku-emails/SKILL.md": "---\nname: pikku-emails\ndescription: >-\n Use when working with Pikku's file-based email templates: authoring HTML/subject/text templates,\n locales, partials and theme, running `pikku emails generate`, and rendering/sending them through\n an EmailService. TRIGGER when: code uses renderEmailTemplate, EmailTemplateName, EmailService,\n SendTemplateEmailInput, LocalEmailService, or imports from .pikku/email/pikku-emails.gen.\n TRIGGER when: the project has an emails/ directory (templates/, locales/, partials/, theme.json)\n or emailTemplatesDir in pikku.config.json. TRIGGER when: user asks to add/edit a transactional\n email (verification, password reset, invitation, receipt), wire email sending, or translate an\n email. DO NOT TRIGGER when: user asks about i18n for the app UI (use pikku-i18n) or auth flows\n in general (use pikku-better-auth).\ninstallGroups: [core]\n---\n\n# Pikku Emails\n\nPikku compiles a directory of plain template files into a typed, dependency-free\nrenderer. `pikku emails generate` reads `emailTemplatesDir` and writes\n`.pikku/email/pikku-emails.gen.ts` (the `renderEmailTemplate` function + per-template\ntypes) and `pikku-emails-meta.gen.json`. Templates are authored as files; the\ngenerated output is never edited by hand.\n\n## Agent Operating Procedure\n\n1. Edit source files under `emailTemplatesDir` only. Never edit `.pikku/email/*`. If the\n directory does not exist yet, run `pikku emails init` rather than creating it by hand.\n2. After any change run `pikku emails generate` (it is also part of `prebuild`, usually\n `pikku bootstrap; pikku all; pikku emails generate`).\n3. Validate by importing `renderEmailTemplate` and rendering with sample data, or run the\n project's typecheck — the generated `data` type will flag missing/wrong variables.\n4. Fix the source cause; do not patch generated files or update hashes by hand.\n\n## Config\n\n```jsonc\n// pikku.config.json\n{\n \"emailTemplatesDir\": \"emails\", // relative to rootDir; omit to disable emails\n \"outDir\": \".pikku\" // gen lands in <outDir>/email/\n}\n```\n\nIf `emailTemplatesDir` is unset the command is a no-op — it logs\n`Skipping emails (set emailTemplatesDir in pikku.config.json to enable).` and exits\ncleanly, so a silent generate is a config problem, not a template problem.\n\n`pikku emails init` scaffolds the directory (starter locales, theme, partials and a\nhello-world template) **and** writes `emailTemplatesDir` into `pikku.config.json` for\nyou. Use it rather than hand-creating the tree; `--force` overwrites an existing\nscaffold.\n\n## Directory layout\n\n```text\nemails/\n theme.json # brand tokens: appName, fonts, colors\n locales/\n en.json # translation strings, nested namespaces\n de.json # one file per locale (filename = locale key)\n partials/\n layout.html # outer wrapper; must include {{content}}\n footer.html # reusable fragment, included with {{> footer}}\n templates/\n verify-email.html # body (required)\n verify-email.subject.txt # subject line (required)\n verify-email.text.txt # plain-text alternative (optional)\n```\n\nA template's **name** is its filename without the `.html` / `.subject.txt` / `.text.txt`\nsuffix (`verify-email` above). `html` and `subject` are required; `text` is optional and,\nwhen present, becomes the plain-text MIME part.\n\n## Templating syntax\n\nPlaceholders are `{{ ... }}`. Resolution order inside a template:\n\n- `{{appName}}` — from `data.appName`, falling back to `theme.appName`.\n- `{{theme.colors.accent}}`, `{{theme.fonts.body}}` — values from `theme.json`.\n- `{{t.verifyEmail.heading}}` — string from the active locale file (`locales/<locale>.json`).\n- `{{verifyUrl}}` — any other key is a **runtime variable**, supplied via `data`.\n- `{{> footer}}` — include a partial from `partials/`.\n- `{{content}}` / `{{subject}}` — only meaningful inside `partials/layout.html`\n (the rendered body and subject). `layout.html` wraps every template if present.\n\nLocale strings may themselves contain variables and partial-free placeholders, e.g.\n`\"subject\": \"{{inviterName}} invited you to join {{organizationName}}\"`. These are\nresolved in the same pass, so a subject of `{{t.invitation.subject}}` expands fully.\n\n## Typed variables (per template)\n\nThe generator extracts the runtime variables each template references and emits a typed\n`data` shape. Extraction is **scoped to the template**: it walks the template's\nhtml/subject/text, the partials it includes, and only the locale keys it actually\nreferences (transitively) — variables from unrelated locale entries do not leak in.\n\n```ts\nimport {\n renderEmailTemplate,\n type EmailTemplateName,\n type EmailTemplateVariables,\n} from './.pikku/email/pikku-emails.gen.js'\n\n// EmailTemplateVariables<'organization-invitation'> =\n// { appName?: ...; inviteUrl?: ...; inviterName?: ...; organizationName?: ... }\n```\n\nEvery extracted variable is emitted **optional** and typed `EmailTemplateValue`\n(`string | number | boolean | null | undefined | object | array`). The type tells you\nwhich variables a template can consume, not which ones it needs — there is no way to\nmark one required, and a template that references none types as `Record<string, never>`.\nReferencing a variable in the template body (rather than only in a locale string) is\nwhat gets it into the type at all.\n\nThat matters because a placeholder with nothing behind it renders as the **empty\nstring** — no error, no leftover `{{…}}`. A typo'd variable name, a missing `data` key\nand a value that isn't a string or number all produce the same silently blank output, so\nrender with sample data and read the result rather than trusting that it compiled.\n\n## Rendering\n\n```ts\nconst rendered = renderEmailTemplate({\n name: 'verify-email', // EmailTemplateName (autocompleted)\n locale: 'en', // optional, defaults to 'en'\n data: { verifyUrl: url }, // EmailTemplateVariables<'verify-email'>\n})\n// rendered: { name, locale, subject, html, text?, variables, hash }\n```\n\nIt is synchronous, and it throws on an unknown template name or an unknown locale —\nthose are the only two failure modes; everything else degrades to blank output.\n\n`hash` is a stable content hash (useful as an idempotency / dedupe key on outgoing mail).\nThe meta file also carries per-locale `htmlHash` / `subjectHash` / `textHash` if you need\nto tell which part changed.\n\n`{{locale}}` is in scope alongside `{{appName}}`, and placeholders are resolved by\nrepeated passes so a locale string containing `{{verifyUrl}}` expands. The loop stops\nafter 5 passes, which only becomes visible with placeholders nested more deeply than\nthat — a shape worth avoiding rather than working around.\n\n## Sending through an EmailService\n\n`@pikku/core/services` defines `EmailService.send(input)` where `input` is one of\n`SendTextEmailInput`, `SendHTMLEmailInput`, or `SendTemplateEmailInput`:\n\n```ts\nimport type { EmailService } from '@pikku/core/services'\n\nawait email.send({\n to: user.email,\n template: { name: 'verify-email', locale: user.locale, data: { verifyUrl } },\n})\n```\n\n`LocalEmailService` (dev/test) captures the payload as-is. To actually render templates\nbefore sending, wrap a delegate service: when `input.template` is present, call\n`renderEmailTemplate` and forward `subject` / `html` / `text` to the delegate (e.g. a\nResend/SES/SMTP service). This wrapper is project-owned because `renderEmailTemplate`\nis generated per project; wire it in `services.ts` and inject it into functions.\n\n```ts\nasync send(input: SendEmailInput) {\n if (!('template' in input) || !input.template) return this.delegate.send(input)\n const r = renderEmailTemplate(input.template as RenderEmailInput<EmailTemplateName>)\n return this.delegate.send({\n to: input.to, from: input.from, subject: r.subject, html: r.html,\n ...(r.text ? { text: r.text } : {}),\n })\n}\n```\n\n## Generated artifacts\n\n- `.pikku/email/pikku-emails.gen.ts` — `renderEmailTemplate`, `EmailTemplateName`,\n `EmailLocale`, `EmailTemplateVariables<T>`, inlined templates/locales/partials/theme.\n- `.pikku/email/pikku-emails-meta.gen.json` — per-template `variables`, `hasHtml/Subject/Text`,\n and per-locale content hashes. Both are regenerated; keep them out of hand edits and\n (typically) git-ignored.\n\n## Gotchas\n\n- New template not appearing → you added `.html` but forgot `.subject.txt` (subject is\n required), or didn't rerun `pikku emails generate`.\n- Variable typed `unknown`/missing → it's only in a locale string for a different template;\n reference it in this template to scope it in.\n- Editing a locale string changes that template's content hash — expected; the hash covers\n the strings the template uses.\n- `layout.html` must contain `{{content}}` or the body is dropped. It is matched by the\n partial name `layout`, so renaming the file opts every template out of the wrapper.\n- A blank spot where a value should be is an unresolved placeholder, not a render\n failure — check the key's spelling and that the value is a string or number (objects\n and arrays resolve to empty).\n", "pikku-fabric/SKILL.md": "---\nname: pikku-fabric\ndescription: 'Build and convert apps for the Pikku Fabric platform. Covers SQLite/libSQL database setup with Kysely, fabric project layout, deploy provider config, `fabric.config.json`, and the pikku-verify workflow. TRIGGER when: user is working on a Fabric-hosted Pikku project, converting an app to Fabric format, or asking about Fabric deployment, database, or project conventions. DO NOT TRIGGER when: user is working on a generic (non-Fabric) Pikku deployment — use pikku-deploy-cloudflare, pikku-deploy-fastify, etc. instead.'\ninstallGroups: [fabric]\n---\n\n# Pikku Fabric\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. **Run structural validation first.** Before any edit, run:\n ```bash\n pikku fabric validate --json\n ```\n This prints every missing file, misconfigured field, and dependency gap with a `fixHint`. Address all `error` findings before proceeding — they block deploy. Resolve `warn` findings before testing — they cause runtime failures. `info` findings are best-practice gaps that are safe to defer.\n2. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n3. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n4. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n5. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n6. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nFabric is a serverless deployment platform for Pikku apps. Every Fabric app runs on Cloudflare Workers with a SQLite database (via libSQL/Turso). This skill covers what's unique to Fabric. For general Pikku concepts, function authoring, HTTP wiring, and more, see `pikku-concepts`, `pikku-http`, `pikku-services`, etc.\n\n## Before you start\n\nAlways run project discovery first:\n\n```bash\nyarn pikku meta context --json\n```\n\nCall the `pikku-meta` tool before grepping or editing a Fabric app.\n\n- Use `section: \"context\"` for the project map: functions, wires, workflows, capabilities, and source files.\n- Use `section: \"clients\"` before frontend/RPC work.\n- Use `section: \"functions\"` to list function ids, then `section: \"function\", id: \"<functionId>\"` for one function.\n- Use `section: \"schemas\"` to list schema names. Only request full JSON Schema bodies with `schemas: [\"SchemaName\"]` for the specific schemas needed.\n\nDo not load every schema body by default; that wastes context and usually makes the model worse.\n\nFor database work:\n\n- Use `pikku-db` for the actual attached Fabric database state: tables, columns, foreign keys, and applied migrations.\n- Use `pikku-meta` `section: \"schemas\"` for code-level JSON Schema contracts, not database introspection.\n- Do not inspect database credentials or connect to the database directly; Fabric Control already exposes the safe introspection surface.\n\n## Database: SQLite via libSQL\n\nFabric apps use SQLite, accessed via Kysely with the libSQL HTTP adapter. NOT PostgreSQL, NOT D1.\n\n### Setup in `services.ts`\n\n```typescript\nimport { Kysely, CamelCasePlugin } from 'kysely'\nimport { LibsqlWebDialect } from '@pikku/kysely-sqlite'\nimport type { DB } from '#pikku/db/schema.gen.js'\n\nconst databaseUrl = await variables.get('DATABASE_URL')\nlet kysely: Kysely<DB>\nif (databaseUrl) {\n kysely = new Kysely<DB>({\n dialect: new LibsqlWebDialect({ url: databaseUrl }),\n plugins: [new CamelCasePlugin()],\n })\n} else if (existingServices?.kysely) {\n kysely = existingServices.kysely as Kysely<DB>\n} else {\n throw new Error('kysely not provided and DATABASE_URL is unset')\n}\n```\n\nFabric injects `DATABASE_URL` as a variable binding when the stage starts. In local dev, `pikku db migrate` uses a local `dev.db` SQLite file.\n\n### Migrations\n\nMigrations are plain `.sql` files at the **project root**, in a directory named\nfor the engine — `db/sqlite/` for SQLite/libSQL stages, `db/postgres/` for\nPostgres ones. Never `db/migrations/`, and never under `packages/functions/`:\nthe deploy pipeline stages `db/<engine>/*.sql` from the root and applies them\nafter upload, so a migration anywhere else is silently never run.\n\n```\ndb/sqlite/\n 0001-init.sql\n 0002-add-users.sql\n```\n\nNumbers must be consecutive and gap-free, and an applied migration is frozen —\ncorrect a mistake with a new forward migration, never by editing or renaming one\nthat has already run (the recorded hash will no longer match).\n\nRun migrations: `pikku db migrate`. It also regenerates `.pikku/db/schema.gen.ts`\n(Kysely types) and `.pikku/db/zod.gen.ts` — there is no separate types step.\n\n**NEVER hand-edit the generated schema** — write a migration and re-run.\n\n### Dev seed data\n\nAlongside the migrations sits `db/<engine>-dev-seed.sql` — `db/sqlite-dev-seed.sql`\nor `db/postgres-dev-seed.sql`. There is no seed command. `pikku db reset` is the\nonly thing that applies it: wipe, migrate, seed. `--no-seed` stops after the\nmigration, for working on an empty-state or onboarding flow the test data hides.\n\nBecause reset always arrives at a database it has just wiped, **the seed file is\nplain `INSERT`s** — no `INSERT OR IGNORE`, no `ON CONFLICT DO NOTHING`, no\n`IF NOT EXISTS`. Nothing applies it twice, so it never has to defend itself. If\nyou find yourself reaching for an idempotent form, that's a sign the data wants\nto be a migration instead.\n\nThis is **test data only**: enough rows that a fresh dev database isn't an empty\napp. It never reaches staging or production — reset refuses `NODE_ENV=production`\nand refuses a database outside the runtime directory. Anything a real environment\nneeds — accounts, role grants — is provisioning, not seeding, and belongs in\n`pikku persona sync` or a migration.\n\nA Better Auth app has a second constraint: the plugins you enable (`admin()`,\n`actor()`, …) each declare columns, and `pikku db migrate` refuses to run while\nthe applied schema is missing any of them. `pikku db generate` writes the\nmigration that closes the gap.\n\n### Column conventions\n\n- Use `SERIAL`/`INTEGER PRIMARY KEY AUTOINCREMENT` for IDs\n- Use `TEXT` for strings, `INTEGER` for booleans (0/1) and timestamps (Unix ms)\n- Use `CHECK` constraints sparingly — prefer app-level validation\n- Table and column names: snake_case in SQL, camelCase in TypeScript (via `CamelCasePlugin`)\n\n## Deploy Provider\n\n`pikku.config.json` (in the project root, not `packages/functions/`) **must** declare the Fabric deploy provider:\n\n```json\n{\n \"deploy\": {\n \"providers\": {\n \"cloudflare\": \"@pikkufabric/deploy-cloudflare\"\n }\n }\n}\n```\n\nWithout this, `pikku deploy plan --provider cloudflare` uses the OSS adapter which lacks Fabric's workflow service wiring.\n\nThe Fabric adapter automatically:\n\n- Injects `SQLiteKyselyWorkflowService` when `DATABASE_URL` is bound\n- Sets up the libSQL workflow queue\n- Wires `workflowQueues: true` for the scaffold\n\nNo manual workflow service setup is needed.\n\n## Project Layout\n\n```\npackages/functions/\n src/\n functions/ # Business logic — one pikkuFunc/workflow per file\n wirings/ # Transport bindings\n *.http.ts # wireHTTP / defineHTTPRoutes / wireHTTPRoutes\n *.channel.ts # wireChannel\n *.queue.ts # wireQueueWorker\n *.schedule.ts # wireScheduler\n *.mcp.ts # wireMCPResource / wireMCPPrompt (an MCP tool is just a function with `mcp: true`)\n *.cli.ts # wireCLI\n services.ts # pikkuServices factory (singleton)\n middleware.ts # Shared middleware\n permissions.ts # Shared permissions\n .pikku/\n db/schema.gen.ts # Kysely types, written by `pikku db migrate` — NEVER hand-edit\napps/app/ # Frontend(s)\ndb/sqlite/ # Plain .sql migrations, numbered, gap-free (project root)\ndb/sqlite-dev-seed.sql # Dev-only test data, applied by `pikku db reset`\npikku.config.json # Pikku + deploy config (project root)\npikkufabric.config.json # Fabric project link + frontends (project root)\n```\n\n## `pikkufabric.config.json`\n\nLinks the repo to a Fabric project and declares its frontends:\n\n```json\n{\n \"projectId\": \"my-project-id\",\n \"production\": {\n \"domain\": \"example.com\"\n },\n \"frontends\": {\n \"app\": {\n \"cwd\": \"apps/app\",\n \"primary\": true,\n \"deploy\": true,\n \"kind\": \"ssr\",\n \"dev\": {\n \"command\": [\"yarn\", \"dev\"],\n \"port\": 7105,\n \"healthPath\": \"/\"\n }\n }\n }\n}\n```\n\n- `projectId`: written by `pikku fabric init` / `link`. Templates ship the\n `__PROJECT_ID__` placeholder — that is _not_ a link, and the CLI treats it as\n unlinked.\n- `production.domain`: optional custom domain. Production always maps to `main`;\n without a domain it lives on the platform `*.pikkufabric.app` hostnames.\n- `frontends`: each entry declares a frontend app with its dev command and port\n\nSeveral CLI messages call this file `fabric.config.json` — `fabric init --force`,\n`fabric link --apiUrl`, and the `domains` commands' \"No fabric.config.json found\".\nThe file the CLI actually reads and writes is `pikkufabric.config.json`; don't\ncreate the shorter name to satisfy an error message.\n\n## RPC is the default transport\n\nIn Fabric apps, most features don't need HTTP wirings. Just write the function with `expose: true` — Pikku generates an RPC client and React Query hooks automatically.\n\n```typescript\nexport const listTasks = pikkuSessionlessFunc({\n expose: true,\n readonly: true,\n func: async ({ kysely }, {}) => {\n return { tasks: await kysely.selectFrom('tasks').selectAll().execute() }\n },\n})\n```\n\nAdd `wireHTTP` only when you need a specific REST shape (webhooks, third-party callers).\n\n### Transport rule\n\n- Always use RPC first.\n- If the function should be callable from the app or other generated clients, prefer `expose: true`.\n- Use `expose: true` for public/generated client access unless the user explicitly wants a private function.\n- Do not add HTTP routes unless the user explicitly asks for HTTP/REST, or the project settings explicitly require HTTP transport.\n- Every new or changed function must have a real description.\n- If function metadata would show `missing description`, the work is not finished yet.\n\n## Run it locally\n\nA Fabric app is two processes: the pikku API server (`:3000`) and the frontend\n(vite). The starter template's `bun run dev` starts **both** and takes the whole\nsession down if either dies — a frontend running against a dead API looks like an\napp bug and is the single most common way to waste an hour here.\n\n```bash\nbun run prebuild # pikku all — codegen must be current before the server boots\nbun run dev\n```\n\nThen open the app, sign up as a real user, and click through what you built.\n**HTTP 200 is not evidence.** These are client-rendered pages: the server returns\n200 with an empty shell, so a page whose component throws still looks fine to\ncurl. Either open it in a browser or drive it headlessly and assert on rendered\ntext.\n\nSecrets come from `process.env`, which the CLI populates from a `.env` in the\nworking directory. `BETTER_AUTH_SECRET` is required — without it the first\nsign-up fails with `Requested secret not found`, which names no key and points at\nno file. The starter template generates one on first `bun run dev`.\n\nIf you are running the two processes yourself rather than through the template's\nscript, run `pikku dev` from the **project root** (it resolves `srcDirectories`\nrelative to the config, so a nested cwd yields a doubled watch path and no hot\nreload).\n\n## Deploy\n\n```bash\npikku fabric login # opens a browser; needs a human, wait for it\npikku fabric init https://github.com/<owner>/<repo>\npikku fabric validate # must pass clean\npikku fabric deploy plan --production\npikku fabric deploy apply --production --auto-apply\n```\n\n`apply` confirms before deploying, and with no TTY to ask — CI, an agent shell —\nit refuses rather than hangs. `--auto-apply` supplies that confirmation; drop it\nonly when a human is at a real terminal.\n\n`init` adopts a **GitHub** repo, and adoption goes through the Pikku Fabric\nGitHub App — the app has to be installed on the account or org that owns the\nrepo, and if it is installed with \"selected repositories\" this one must be in\nthe selection. There is no CLI flag that works around a missing installation:\n`init` returns \"Connect the GitHub account '<owner>'\". Send the user to install\nit, or create the project in the console instead (which provisions a Fabric-hosted\ngit repo you push to) and write the returned `projectId` into\n`pikkufabric.config.json` yourself.\n\nDeploy refuses to run unless the target branch equals its upstream — the guard\ncompares `main` against `main@{upstream}`. So the remote you pushed to must be\nthe one the branch tracks; a stale `origin` left over from scaffolding blocks\nthe deploy with \"local HEAD … ≠ remote …\" even though your code is pushed.\n`git branch --set-upstream-to=<remote>/main main` before deploying.\n\n## Versioning\n\nFunctions with `expose: true` are versioned via `versions.pikku.json`. When you change a function's input or output schema, you must bump its version number — otherwise `pikku all` will report a breaking change and callers' generated clients become stale.\n\nThe `pikku-verify` tool catches this automatically.\n\n## After every code change\n\nAlways call the `pikku-verify` tool after modifying functions, wirings, or schemas. It runs:\n\n1. `pikku all` — regenerates all codegen, checks version compliance\n2. `tsc --noEmit` — validates TypeScript types\n\nThe output card shows whether any breaking changes were detected.\n\n## Hard rules\n\nThese apply in every Fabric app:\n\n- **No `process.env`** — use `variables.get('NAME')` and `secrets.getSecret('NAME')`. Declare with `defineVariable` / `defineSecret`.\n- **No `as any`** — fix types properly.\n- **No generic `Error`** — throw `NotFoundError`, `ConflictError`, `BadRequestError`, `UnauthorizedError` from `@pikku/core/errors`.\n- **No auth checks in function bodies** — use `permissions:` field on the function config with a `pikkuPermission` factory.\n- **No hand-editing `.pikku/db/schema.gen.ts`** — write a migration and re-run `pikku db migrate`.\n- **One runtime unit per file** — never define multiple functions/workflows in a single source file.\n- **Workflow steps don't need manual wiring** — `pikkuSessionlessFunc` step functions in `*.steps.ts` files are auto-discovered by codegen.\n\n## Converting an existing app to Fabric format\n\nStart by running the structural validator — it tells you exactly what is missing:\n\n```bash\npikku fabric validate --json\n```\n\nFix every `error` and `warn` in the output before continuing. Then:\n\n1. **Replace the database layer**: swap PostgreSQL/MySQL queries for Kysely + libSQL. Convert schema to SQLite-compatible SQL migrations in `db/sqlite/`.\n2. **Replace route handlers with pikkuFuncs**: extract business logic into `pikkuFunc`/`pikkuSessionlessFunc`, add `wireHTTP` or `expose: true` for transport.\n3. **Replace DI/IoC with pikkuServices**: move service construction to `createSingletonServices` in `services.ts`.\n4. **Replace `process.env` calls**: plain config becomes `defineVariable` + `variables.get()`, anything sensitive becomes `defineSecret` + `secrets.getSecret()`.\n5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles`.\n6. **Add `pikkufabric.config.json`** at project root with `projectId`, `production.domain`, and `frontends` (production is always `main`, so there is no `production.branch`).\n7. **Run `pikku all`** — verify codegen succeeds and there are no type errors.\n8. **Run `pikku fabric validate`** once more to confirm no structural issues remain.\n", "pikku-fabric-debug/SKILL.md": "---\nname: pikku-fabric-debug\ndescription: 'Debug a deployed Fabric stage from the CLI — read logs, find recent errors, follow a single request end-to-end by traceId, and check request/error/latency metrics. TRIGGER when: a deployed Fabric app is erroring, timing out, or behaving differently than local; the user asks \"why is prod failing\", \"check the logs\", \"what happened to this request\"; or a deploy succeeded but the app misbehaves. DO NOT TRIGGER when: the failure reproduces locally (debug it locally), the deploy itself failed (use pikku-fabric — that is a build/config problem, not a runtime one), or the project is not deployed to Fabric.'\ninstallGroups: [fabric]\n---\n\n# Debugging a deployed Fabric stage\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Reproduce locally first. If it fails locally too, debug it there — the\n deployed stage adds cost and latency to every iteration.\n2. Start from `errors`, not `logs`. Errors are already filtered and carry the\n traceId that unlocks the rest.\n3. Follow one trace end-to-end before forming a theory. A single failing request\n tells you more than a hundred unrelated log lines.\n4. Fix the source cause and redeploy. Never leave the diagnosis at \"it is flaky\".\n5. Confirm the fix against the same stage — recheck `errors` for the function.\n\nEvery command below requires a logged-in CLI and a linked project. Both fail\nwith the exact remediation if not:\n\n```\nNot logged in. Run `pikku fabric login` first.\nNo fabric project linked. Run `pikku fabric link` first.\n```\n\n## The loop\n\n**1 — What is broken?**\n\n```bash\npikku fabric errors -b main # branch defaults to main\npikku fabric errors -b main --function createOrder\n```\n\nPrints a `WHEN | FUNCTION | TRACE | MESSAGE` table. The message is **truncated\nto 100 characters** — treat it as a label, not the full error. The TRACE column\nis the input to the next step.\n\n**2 — What happened in that one request?**\n\n```bash\npikku fabric trace <traceId> -b main\npikku fabric trace <traceId> -b main --json\n```\n\n`--branch` is **required** here (no default). Each event prints as:\n\n```\n<timestamp> <scriptName> <wireType>:<wireId> <duration>ms — <error|message|outcome>\n```\n\nThis is the whole request across the stage — every unit it touched, in order,\nwith per-event durations. The last event before the failure is where to look.\n\n**3 — Is it one request or the whole stage?**\n\n```bash\npikku fabric metrics -b main # last 24h\npikku fabric metrics -b main --hours 2 --function createOrder\n```\n\n`--branch` is **required** here too; `--hours` defaults to 24.\n\nRows are `reqs= err= (rate%) avg= min= max=` per bucket. A single bad request\nwith a healthy error rate is a data problem; a climbing error rate is a\ndeployment or dependency problem. `--json` additionally returns a `wireTypes`\nbreakdown (requests per http/queue/scheduler/…) that the table output omits.\n\n**4 — Wider context around the failure**\n\n```bash\npikku fabric logs -b main\npikku fabric logs -b main --level warn\npikku fabric logs -b main -f # follow\n```\n\n`--branch` is **required** — `logs` throws `Specify --branch <branch-name>.`\nwithout it, even though the flag reads as optional.\n\n**5 — Is the running code the code you think it is?**\n\n```bash\npikku fabric status # active + in-flight deployment, per stage, with gitSha\n```\n\nCheck this *before* deep-diving. A stage still serving an older `gitSha`, or a\ndeploy stuck in flight, explains a whole class of \"my fix did nothing\".\n\n## Known gaps — do not misread these as bugs in your app\n\n- **`pikku fabric logs --since` and `--deployment` are accepted and ignored.**\n They are declared as options but the command never reads them, so\n `--since 15m` silently returns the same default window as no flag at all. Do\n not conclude \"nothing happened in the last 15 minutes\" from it. Narrow by\n `--level`, or by `--function` via `errors`, instead.\n- **`--follow` is a 2-second client-side poll, not a server stream** — despite\n its own help text reading \"Stream new logs (SSE)\". Server-side SSE is planned;\n the backend doesn't push natively today. It\n dedups against what it already printed, so it behaves like `tail -f`, but new\n entries can appear up to ~2s late and it holds the process open until killed.\n\n## What NOT to do\n\n- **Do not SSH anywhere or query the telemetry backend directly.** These\n commands are the supported surface; anything lower-level is Fabric-internal\n and will not exist for your project.\n- **Do not debug by redeploying with added `console.log`s.** Get the traceId,\n read the trace. A deploy cycle per hypothesis is the slow path.\n- **Do not read the truncated `errors` message as the full error.** Always\n confirm against `trace` before changing code.\n- **Do not treat an empty `errors` table as \"the app is fine\"** — a request that\n returns a wrong 200 logs nothing. Check `metrics` for the outcome mix.\n", "pikku-feature/SKILL.md": "---\nname: pikku-feature\ndescription: 'Drive create-a-feature work for a Pikku project: discover project context, work on a feature branch, implement + verify + commit, and ask the user to review via the diff. TRIGGER when: the user asks to \"create a feature\", \"build a todo app\", \"add X to my Pikku project\", \"wire up a new endpoint\", or anything that implies turning a natural-language request into Pikku functions/wirings/migrations. DO NOT TRIGGER when: the user asks for a one-off code edit in an existing function, or asks about Pikku concepts (use pikku-concepts).'\ninstallGroups: [core]\nallowed-tools: Bash(yarn pikku meta *), Bash(yarn pikku all *), Bash(yarn tsc), Bash(git status *), Bash(git diff *), Bash(git switch *), Bash(git checkout *), Bash(git checkout -b *), Bash(git add *), Bash(git commit *), Bash(git log *), Bash(git branch *)\nargument-hint: '<feature description>'\n---\n\n# Pikku Create-a-Feature\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nEnd-to-end flow: **discover → state intent → branch → implement → verify → commit → hand to reviewer**.\n\nThere is **no plan JSON**. The branch + diff IS the contract. The reviewer\nsees real, compiled, working code. Apply = merge. Reject = `git branch -D`.\n\n## Stage 1 — Discover\n\nRun **once** at the start of every feature request:\n\n```bash\nyarn pikku meta context --json\n```\n\nThis single call returns functions, wires, middleware, permissions, workflows,\n`capabilities` (which wire types are in use), and `layout` (where new files\nshould land).\n\nOnly fall back to targeted commands when you need full input/output JSON\nschemas (`yarn pikku meta functions get <id>`) or workflow steps\n(`yarn pikku meta workflows get <id>`).\n\n**Capability rule:** do not introduce new wires of a type whose\n`capabilities.<type>` is `false` unless the user explicitly asked for it.\n\n## Stage 2 — State intent in plain English (BEFORE writing code)\n\nBefore touching any files, give the user one paragraph stating exactly what\nyou'll do. This is the lightweight \"plan\" — it is chat, not JSON.\n\n> I'll add a `todos` table via a new migration in `sql/`, and two\n> `pikkuSessionlessFunc`s (`createTodo`, `listTodos` with\n> `readonly: true`) in `packages/functions/src/functions/`. Both\n> `expose: true`, so they'll be reachable via the auto-generated RPC\n> client and React Query hooks — no HTTP wiring needed. No new\n> dependencies. OK to proceed?\n\nWait for the user to confirm or redirect. They can ask for changes (\"use the\nexisting tasks table\" / \"make it a queue not http\") in normal chat — no\nschema, no JSON, no ceremony.\n\n**Non-interactive runs (auto mode, CI, batch jobs):** state intent in one\nparagraph and proceed without waiting. Surface course corrections promptly\nin the post-implementation report.\n\n## Stage 3 — Branch off\n\nAfter confirmation, ensure the working tree is clean and create a feature\nbranch off the current default branch (whatever `git branch --show-current`\nreturns at the start — `main`, `master`, `develop`, all fine):\n\n```bash\ngit status\ngit switch -c feature/<short-slug>\n```\n\nIf the working tree is dirty, **stop and ask** — never stash silently or\noverwrite uncommitted work.\n\n## Stage 4 — Implement\n\nWrite the code as a normal human contributor would. Use the project's\nexisting conventions (look at neighbour files in `srcDirectories[0]/functions/`\nand `.../wirings/` for style).\n\n### RPC is the default transport\n\n**Just write the function with `expose: true`** — that's enough to make it\ncallable. Pikku auto-generates an RPC client (and React Query hooks if the\nproject's `clientFiles.reactQueryFile` is set) from every exposed function.\nYou do **not** need an HTTP wiring for callers to reach the function.\n\nDefault flow for a feature:\n\n1. Write the function file with `expose: true` (and `readonly: true` for\n reads).\n2. Run `pikku all` — RPC map, fetch client, and React Query hooks are\n regenerated. Frontends call `useListTodos()` / `mutation.mutate(...)`\n without you wiring anything.\n\nAdd an HTTP wiring **only when** the feature genuinely needs a specific\nREST shape (third-party callers, webhooks, REST-conventional URLs). Most\nin-app features don't.\n\n### Hard rules that always apply\n\n- **`expose: true`** for any function called from a frontend or another\n service. Without it the RPC client won't generate hooks for it.\n- **`readonly: true` for queries.** Mark read functions as `readonly: true`\n on the function config. The runner uses this to enforce read-only sessions\n (a write func called under a readonly session is rejected). The RPC layer\n also uses it to pick `useQuery` (cacheable) vs `useMutation` for client\n hooks. Mutations leave `readonly` unset (or `false`).\n- **`kind` ⇔ `auth` coupling for HTTP wirings (when you have one).** If the\n function is `pikkuFunc` (session-aware), the HTTP wiring needs\n `auth: true`. `pikkuSessionlessFunc` ⇒ `auth: false`. Mismatching is a\n hard error (PKU573).\n- **HTTP method by intent (when you wire HTTP).** Reads → `GET`. Writes →\n `POST`/`PUT`/`PATCH`/`DELETE` per REST conventions.\n- **Workflows.** Prefer `pikkuWorkflowGraph` (DSL) over\n `pikkuWorkflowComplexFunc`. `mode: 'inline'` is sync; `'distributed'` is\n queue-dispatched.\n- **Auth checks belong on the function or wiring**, not in function bodies.\n Use the `permissions` field with a `pikkuPermission` factory.\n- **Throw typed errors** from `@pikku/core/errors` — `NotFoundError`,\n `ConflictError`, `BadRequestError`. Never bare `Error`.\n- **Migrations are inline SQL files** in the project's migrations dir\n (typically `sql/`). Use a numbered prefix matching existing files.\n- **Secrets and env-vars: NEVER `process.env`.** Declare them with\n `defineSecret` (sensitive) or `defineVariable` (non-sensitive) — both with a\n zod schema for type-safe access. Read variables with\n `services.variables.get('NAME')`. Secrets are **not available in functions** —\n read them in `services.ts` with `secrets.getSecret('NAME')` and pass the value\n into the service the function uses. See the **pikku-config** skill for the full\n pattern (including OAuth2 credentials). This applies even in `config.ts`.\n\n### Conventions to copy from neighbours\n\nSome patterns vary by project; **read a neighbour file before writing**:\n\n- **Function shape**: zod schemas as exported `const`s (`CreateTodoInput`,\n `CreateTodoOutput`) passed to `input`/`output` on the func config — vs\n generic-typed config. Schema name **must match codegen expectations** (the\n exported const name = the schema name in generated `.gen.json`).\n- **Imports**: usually `'#pikku'` for `pikkuFunc` / `pikkuSessionlessFunc`\n etc. Copy what neighbours do.\n- **Service usage**: e.g. `kysely`, `redis`. Look at how an existing function\n destructures services from its first arg. **Check `application-types.d.ts`**\n to see whether services like `kysely` are typed (`Kysely<DB>`) or untyped\n (`Kysely<any>`) — that drives whether you can lean on generated DB types\n or have to coerce manually.\n- **DB schema namespace**: many projects put tables under a `CREATE SCHEMA`\n (e.g. `app.todos`). Read the first migration in `sql/` to see the\n convention; reuse helper functions/triggers (e.g. `update_last_updated_at`)\n rather than redefining them.\n- **HTTP wiring style** (only relevant if you're adding one). Two common\n shapes — match what the project already uses:\n - Per-route `wireHTTP({ method, route, func, auth })`.\n - Single map: `const routes = defineHTTPRoutes({ auth: false, routes: {\nfooName: { method: 'post', route: '/foo', func: fooFunc } }}); wireHTTPRoutes(routes)`.\n\nFor shared wiring files (e.g. `todos.http.ts` holding both create and list):\ncreate the file with imports if it doesn't exist; **append** wire calls and\nadd missing imports if it does.\n\n## Stage 5 — Verify\n\nBoth must complete cleanly **for your changes** before committing:\n\n```bash\nyarn pikku all\n# Type-check the workspaces you touched:\ncd packages/functions && npx tsc --noEmit\n```\n\nNotes on running `tsc`:\n\n- A root-level `yarn tsc` may be a no-op in monorepos that don't define a\n `tsc` script in each workspace. Don't trust an exit-zero from the root if\n no actual checking happened — verify by running `npx tsc --noEmit` in the\n package(s) you touched.\n\n### What \"fails\" means\n\n**Trust the exit code, not the stderr noise.** `yarn pikku all` may print\nwarnings, `[PKUxxx]` messages, even `level: critical` log lines, while\nstill exiting `0` — those are pre-existing project state, not your\nproblem. Same for `meta context --json`: it streams logs to stderr that\nlook scary on a clean baseline. The exit code is the source of truth.\n\nIf a command exits non-zero, that's a real failure — fix or stop.\n\n### Baseline noise — only your errors matter\n\nMany real-world projects ship with pre-existing warnings or errors\n(legacy types, version drift, gen-layer messages). Those are not your\nproblem; do not \"fix\" them.\n\nTo distinguish your errors from baseline:\n\n1. **Before implementing** (Stage 4), capture the baseline:\n ```bash\n yarn pikku all 2>&1 | tee /tmp/pikku-before.log\n ```\n2. **After implementing**, compare:\n ```bash\n yarn pikku all 2>&1 | tee /tmp/pikku-after.log\n diff /tmp/pikku-before.log /tmp/pikku-after.log\n ```\n\nA clean diff means your changes introduced no new issues — even if the\nunderlying logs both show pre-existing warnings.\n\nIf something genuinely failed because of YOUR change, fix the actual issue.\n**Do not** mask errors with `as any`, `@ts-ignore`, or `--no-verify`. If\nyou're stuck, surface the failure to the user — don't hand them a broken\nbranch.\n\n## Stage 6 — Commit\n\n```bash\ngit add <the files you changed>\ngit commit -m \"feat: <short title>\"\n```\n\nStage the files you actually touched, by path. `git add -A` / `git add .` also\nsweeps up regenerated artifacts you didn't mean to commit and, where more than\none agent shares the checkout, another agent's in-progress work — which lands in\nyour branch and silently breaks theirs.\n\n## Stage 7 — Hand off\n\nTell the user the branch name and how to review. Two options:\n\n- **Local review:** open the pikku console — the changes view diffs the\n current branch against `main` with pikku-aware structure (added functions,\n new wires, migrations).\n- **PR review:** ask before pushing. Once they confirm, `git push -u origin\nfeature/<slug>` and surface the PR-create URL.\n\nDo not push without explicit confirmation. Do not merge.\n\n## Hard constraints\n\nThe skill's `allowed-tools` does **not** permit:\n\n- `yarn add` / `npm install` / dependency changes (ask the user first)\n- `yarn dbmigrate` (never run migrations against the real DB during planning)\n- `pikku deploy apply` (never deploy)\n- secret writes\n- network calls beyond what the implementation requires\n\nIf the feature genuinely needs any of these, **stop and ask** with a clear\nexplanation of why and what would change.\n\n## Output discipline\n\n- Stage 2 (intent statement) is plain English, one paragraph.\n- Between stages, give one-line updates: \"Discovered 30 functions, http+queue\n in use. Drafting intent...\" → \"Branch `feature/todos` created, implementing...\"\n → \"`pikku all` clean, `tsc` clean, committed. Review via console or run\n `git diff main`.\"\n- Don't narrate file-by-file. Only surface what's interesting (new patterns,\n judgment calls, things you suppressed).\n", "pikku-gateway-slack/SKILL.md": "---\nname: pikku-gateway-slack\ndescription: >-\n Use when integrating Slack with a Pikku app. Covers SlackGatewayAdapter, slash commands, OAuth\n flow, message handling, and signature verification. TRIGGER when: code uses SlackGatewayAdapter,\n parseSlashCommand, buildSlackInstallUrl, or user asks about Slack integration, Slack bots, or\n @pikku/gateway-slack. DO NOT TRIGGER when: user asks about general gateway/webhook patterns (use\n pikku-trigger).\n---\n\n# Pikku Gateway Slack\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/gateway-slack` provides a Slack Events API gateway adapter, slash command handling, OAuth installation flow, and message utilities.\n\n## Installation\n\n```bash\nyarn add @pikku/gateway-slack @slack/web-api\n```\n\n## API Reference\n\n### `SlackGatewayAdapter`\n\n```typescript\nimport { SlackGatewayAdapter } from '@pikku/gateway-slack'\n\nconst adapter = new SlackGatewayAdapter({\n signingSecret: string,\n tokenResolver: (teamId: string) => Promise<string | null>,\n})\n```\n\nBridges Slack Events API webhooks with Pikku's gateway system for processing Slack events as Pikku functions.\n\n**There is no `botToken` option.** One adapter serves every workspace, and the\nbot token is resolved per `team_id` through `tokenResolver` — normally a lookup\nagainst the row `exchangeSlackOAuthCode` wrote at install time. Returning `null`\nthrows for that event. `WebClient`s are cached per team, so call\n`invalidateClient(teamId)` after a token rotation.\n\n**Methods:**\n\n- `verifyWebhook(data, request?)` — asserts the signature, then answers the `url_verification` challenge. It **fails closed**: no request access, missing headers, a stale timestamp, or an HMAC mismatch all throw `UnauthorizedError` before parse or the handler runs\n- `parse(data)` — normalizes an `event_callback` into a `GatewayInboundMessage`, or returns `null` for anything to ignore\n- `createBoundSend(teamId, channelId, threadTs?)` — the real send path\n- `send(senderId, message)` — **a deliberate no-op.** The generic signature carries no channel context, and the gateway runner calls it for auto-send, so it swallows rather than throws. A reply written through it silently never reaches Slack\n- `getClientForTeam(teamId)` / `invalidateClient(teamId)` / `close()`\n\n`parse` returns `null` — meaning the event is dropped — for anything that isn't a\n`message` or `app_mention`, for bot messages (loop prevention), for any subtype\nother than `thread_broadcast`, and for events with no `user` or no `text`.\n`metadata` carries `{ teamId, channelId, threadTs, messageTs, eventType }`, with\n`threadTs` falling back to the message's own `ts` so replies always land\nin-thread.\n\n### `SlackGatewayHelper`\n\nWraps a parsed message plus the adapter and binds the channel/thread for you:\n\n```typescript\nconst slack = new SlackGatewayHelper(data, adapter)\nawait slack.sendText('Thinking…') // sends now\nreturn slack.reply('Here is the answer') // auto-sent by the runner\n```\n\nAlso: `send(message)`, `replyBlocks(blocks)`, and the `channelId` / `threadTs` /\n`teamId` getters.\n\n### Slash Commands\n\n```typescript\nimport { parseSlashCommand, respondToSlashCommand } from '@pikku/gateway-slack'\n\nconst command = parseSlashCommand(data)\n// { raw, subcommand, args, argsList, teamId, userId, channelId, triggerId, responseUrl }\nawait respondToSlashCommand(command.responseUrl, { text: 'Done!' })\n```\n\nThe parsed result is camelCase — reach for `command.responseUrl`, not\n`command.response_url`; the underlying snake_case payload is on `command.raw`.\n`text` is split on whitespace: the first word becomes `subcommand`, the rest\n`args`/`argsList`.\n\n`respondToSlashCommand` posts to the `response_url` and **ignores the result** —\na rejected response is invisible. Use it for the delayed reply when work exceeds\nSlack's 3-second acknowledgement window.\n\n### OAuth Flow\n\n```typescript\nimport {\n buildSlackInstallUrl,\n exchangeSlackOAuthCode,\n RECOMMENDED_BOT_SCOPES,\n} from '@pikku/gateway-slack'\n\nconst installUrl = buildSlackInstallUrl({\n clientId: config.slackClientId,\n scopes: RECOMMENDED_BOT_SCOPES,\n redirectUri: config.slackRedirectUri,\n})\n\nconst tokens = await exchangeSlackOAuthCode({\n clientId: config.slackClientId,\n clientSecret: config.slackClientSecret,\n code: oauthCode,\n redirectUri: config.slackRedirectUri,\n})\n```\n\n### Signature Verification\n\n```typescript\nimport { verifySlackSignature } from '@pikku/gateway-slack'\n\nverifySlackSignature(signingSecret, signature, timestamp, body): boolean\n```\n\n**Signature before timestamp** — the two middle arguments are both strings, so\nswapping them compiles and simply never verifies. `signature` is the raw\n`x-slack-signature` header (`v0=…`), `timestamp` is `x-slack-request-timestamp`\nin Unix seconds, and `body` must be the **raw** request body: any re-serialization\nchanges the HMAC.\n\nIt returns `false` rather than throwing, including for a timestamp more than 5\nminutes off (replay protection). The adapter already calls this for you — reach\nfor it directly only outside the gateway path, e.g. in a slash-command route.\n\n## Usage Patterns\n\n### Slack Bot Gateway\n\n```typescript\nimport { SlackGatewayAdapter } from '@pikku/gateway-slack'\n\nconst slackGateway = new SlackGatewayAdapter({\n signingSecret: config.slackSigningSecret,\n tokenResolver: async (teamId) => {\n const row = await kysely\n .selectFrom('slackInstall')\n .select('botToken')\n .where('teamId', '=', teamId)\n .executeTakeFirst()\n return row?.botToken ?? null\n },\n})\n```\n\n### Slash Command Handler\n\nSlack gives you 3 seconds to acknowledge, so anything slower answers immediately\nand posts the real result to `responseUrl` afterwards:\n\n```typescript\nconst handleSlashCommand = pikkuSessionlessFunc({\n title: 'Handle Slack Command',\n func: async ({ db }, data) => {\n const command = parseSlashCommand(data)\n await respondToSlashCommand(command.responseUrl, {\n text: `Processed: ${command.args}`,\n response_type: 'ephemeral',\n })\n },\n})\n```\n", "pikku-http/references/http-options.md": "# wireHTTP / defineHTTPRoutes / wireHTTPRoutes — full option reference\n\n## `wireHTTP(config)`\n\nWire a single function to an HTTP endpoint. Import from `#pikku`.\n\n| Option | Type | Notes |\n| --- | --- | --- |\n| `method` | `'get' \\| 'post' \\| 'put' \\| 'patch' \\| 'delete' \\| 'head' \\| 'options'` | HTTP verb |\n| `route` | `string` | e.g. `/books/:bookId` — `:params` become `data` fields |\n| `func` | `PikkuFunc` | The function to call |\n| `auth?` | `boolean` | Override default auth (`true` = require session) |\n| `tags?` | `string[]` | For grouping, middleware targeting |\n| `middleware?` | `PikkuMiddleware[]` | Per-route middleware |\n| `sse?` | `boolean` | Enable Server-Sent Events — **`method: 'get'` only** |\n| `query?` | `Array<keyof In>` | **`method: 'post'` only** — input fields also read from the query string |\n| `contentType?` | `'xml' \\| 'json'` | Response content type |\n| `timeout?` | `number` | Request timeout in ms |\n| `headers?` | `HTTPHeadersSchema` | Expected headers schema |\n\n`sse` and `query` are constrained by the config union rather than by a runtime\ncheck, so a `sse: true` on a `post` fails to typecheck rather than silently\nserving a normal response. OpenAPI metadata is not declared here — it is derived\nfrom the function's `description`/`summary` and its input/output schemas.\n\n## `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)`\n\nGroup routes with shared configuration. Groups are composable and nestable. Import from `#pikku`.\n\n```typescript\nconst routes = defineHTTPRoutes({\n basePath?: string, // Prepended to all route paths\n tags?: string[], // Applied to all routes in group\n auth?: boolean, // Default auth for all routes (overridable per-route)\n middleware?: PikkuMiddleware[],\n routes: {\n [key: string]: {\n method: string,\n route: string,\n func: PikkuFunc,\n auth?: boolean, // Override group auth\n middleware?: PikkuMiddleware[],\n }\n }\n})\n\nwireHTTPRoutes({\n basePath?: string, // Top-level prefix (e.g. '/api/v1')\n middleware?: PikkuMiddleware[],\n routes: {\n [key: string]: ReturnType<typeof defineHTTPRoutes>,\n }\n})\n```\n\nConfig cascading rules:\n\n- `basePath` — concatenates down the chain\n- `tags` — merge (union)\n- `auth` — child overrides parent\n", "pikku-http/SKILL.md": "---\nname: pikku-http\ndescription: >-\n Use when adding HTTP routes, REST APIs, web endpoints, or SSE streams to a Pikku app. Covers\n wireHTTP, defineHTTPRoutes, route groups, auth, middleware, SSE, and generated\n fetch client. TRIGGER when: code uses wireHTTP/defineHTTPRoutes/wireHTTPRoutes, user asks about\n REST endpoints, API routes, SSE, or the generated fetch client. DO NOT TRIGGER when: user asks\n about WebSocket (use pikku-websocket), queue workers (use pikku-queue), or deployment (use\n pikku-deploy-*).\ninstallGroups: [core]\n---\n\n# Pikku HTTP Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to HTTP endpoints. Supports single routes, composable route groups, auth, middleware, SSE, and auto-generated type-safe clients. (Authorization lives on the function, not the wiring — see `pikku-permissions`.)\n\n## Before You Start\n\nRun these commands to understand the current project:\n\n```bash\npikku info functions --verbose # See existing functions, their types, tags, middleware\npikku info tags --verbose # Understand project organization and naming conventions\npikku info middleware --verbose # See what middleware is already applied\n```\n\nFollow existing patterns you find (naming, tag usage, file organization). See `pikku-concepts` for the core mental model.\n\n## API Reference\n\nAll three come from `#pikku` (the generated `.pikku/pikku-types.gen.js`), which\nbinds them to your project's service, session and middleware types. The\n`@pikku/core/http` versions are the unbound generics — they compile, but you\nlose the typing that makes the wiring worth having.\n\n- `wireHTTP(config)` — wire one function to one endpoint.\n- `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)` — group routes with shared config; composable/nestable.\n\nFunction input/output types come from the function's own `input:`/`output:` zod schemas — never declared in the wiring. Route `:params`, query params, and body are merged into the function's `data` arg (see Data Flow).\n\nConfig cascading across groups: `basePath` concatenates down the chain, `tags` merge (union), `auth` child overrides parent.\n\nFor the full option tables (every `wireHTTP` field, the `defineHTTPRoutes`/`wireHTTPRoutes` config shape), read `references/http-options.md`.\n\n### `addHTTPMiddleware(pattern, middlewares)`\n\n```typescript\naddHTTPMiddleware('*', [authBearer()]) // All routes\naddHTTPMiddleware('/api/*', [rateLimit()]) // Pattern match\n```\n\n> HTTP-route-level permissions (`addHTTPPermission`, a `permissions` field on the wiring) were removed in #972. Declare authorization on the function definition (`pikkuFunc({ permissions })`, see `pikku-permissions`), or app-wide via `addGlobalPermission`. Tags/patterns are for *middleware* only now.\n\n## Data Flow\n\nPikku merges route params, query params, and request body into a single `data` object:\n\n```typescript\n// POST /books/42?format=pdf with body { title: \"New Title\" }\nwireHTTP({ method: 'post', route: '/books/:bookId', func: updateBook })\n// → updateBook receives: { bookId: \"42\", format: \"pdf\", title: \"New Title\" }\n```\n\n## Usage Patterns\n\n### Single Route\n\n```typescript\nwireHTTP({\n method: 'get',\n route: '/books/:bookId',\n func: getBook,\n})\n```\n\n### Route Groups (Recommended for CRUD)\n\n```typescript\nconst booksRoutes = defineHTTPRoutes({\n tags: ['books'],\n routes: {\n list: { method: 'get', route: '/books', func: listBooks, auth: false }, // per-route override\n get: { method: 'get', route: '/books/:bookId', func: getBook },\n create: { method: 'post', route: '/books', func: createBook },\n delete: { method: 'delete', route: '/books/:bookId', func: deleteBook },\n },\n})\n\nconst todosRoutes = defineHTTPRoutes({\n auth: false, // group-level default, overridable per-route\n tags: ['todos'],\n routes: {\n list: { method: 'get', route: '/todos', func: listTodos },\n },\n})\n\nwireHTTPRoutes({\n basePath: '/api/v1',\n middleware: [cors()],\n routes: { books: booksRoutes, todos: todosRoutes },\n})\n// Results in: GET /api/v1/books, POST /api/v1/books, GET /api/v1/todos, etc.\n```\n\n### Auth\n\n```typescript\n// Public route (no auth)\nwireHTTP({ method: 'get', route: '/books', func: listBooks, auth: false })\n\n// Authenticated route (default when a global auth middleware is set)\nwireHTTP({ method: 'delete', route: '/books/:bookId', func: deleteBook })\n```\n\nAuthorization is not a wiring concern — declare it on the function via `permissions` (see `pikku-permissions`), or app-wide via `addGlobalPermission`.\n\n### Middleware\n\n```typescript\nimport { cors, authBearer } from '@pikku/core/middleware'\n\n// Global middleware\naddHTTPMiddleware('*', [\n cors({ origin: 'https://app.example.com', credentials: true }),\n authBearer(),\n])\n\n// Scoped middleware\naddHTTPMiddleware('/api/*', [rateLimit({ maxRequests: 100, windowMs: 60_000 })])\n\n// Per-route middleware\nwireHTTP({\n method: 'delete',\n route: '/books/:bookId',\n func: deleteBook,\n middleware: [auditLog],\n})\n```\n\n### SSE (Server-Sent Events)\n\n`sse: true` is only accepted on `method: 'get'` — the wiring union offers it on\nno other verb.\n\n```typescript\nwireHTTP({\n method: 'get',\n route: '/todos',\n func: getTodos,\n sse: true,\n})\n\nconst getTodos = pikkuFunc({\n title: 'Get Todos',\n func: async ({ db }, {}, { channel }) => {\n const todos = await db.getTodos()\n\n if (channel) {\n for (const todo of todos) {\n channel.send({ todo })\n await sleep(100)\n }\n return\n }\n\n return { todos }\n },\n})\n```\n\n`channel` is on the **wire** — the func's third argument — not on services, and\nit is optional because the same function can be reached over plain HTTP or RPC,\nwhere there is no stream to send on. The `if (channel)` guard is what lets one\nfunction serve both; the return value is the non-streaming answer.\n\n### Generated Fetch Client\n\nAfter `npx pikku all`, a type-safe client is generated:\n\n```typescript\nimport { pikkuFetch } from '#pikku/pikku-fetch.gen.js'\n\npikkuFetch.setServerUrl('http://localhost:4002')\n\nconst books = await pikkuFetch.get('/api/v1/books', {})\nconst book = await pikkuFetch.get('/api/v1/books/:bookId', { bookId: '42' })\nconst created = await pikkuFetch.post('/api/v1/books', {\n title: 'The Pikku Guide',\n author: 'You',\n})\n\npikkuFetch.setAuthorizationJWT(token)\nconst deleted = await pikkuFetch.delete('/api/v1/books/:bookId', {\n bookId: created.bookId,\n})\n```\n\n## Complete Example\n\nFunctions live in their own files (one per file) and supply behavior + `permissions`; the wiring file imports them and wires routes. Sessionless funcs need no session; `pikkuFunc` does.\n\n```typescript\n// functions/books.functions.ts\nimport { pikkuFunc, pikkuSessionlessFunc } from '#pikku'\n\nexport const listBooks = pikkuSessionlessFunc({\n title: 'List Books',\n func: async ({ db }, { limit }) => ({ books: await db.listBooks(limit) }),\n})\n\nexport const getBook = pikkuFunc({\n title: 'Get Book',\n description: 'Retrieve a book by ID',\n func: async ({ db }, { bookId }) => await db.getBook(bookId),\n permissions: { user: isAuthenticated },\n})\n\n// wirings/books.http.ts — same defineHTTPRoutes/wireHTTPRoutes shape as the Route Groups example above\nimport { addHTTPMiddleware } from '#pikku'\nimport { cors, authBearer } from '@pikku/core/middleware'\n\naddHTTPMiddleware('*', [cors(), authBearer()])\n```\n", "pikku-i18n/SKILL.md": "---\nname: pikku-i18n\ndescription: 'Wire i18n into a Pikku frontend with Paraglide JS (inlang). English by default, every user-facing string is a typed message function (`m.some__key()`) compiled from `messages/<locale>.json`, and additional languages are served under `/fr` `/de` URL prefixes. TRIGGER when: scaffolding or editing a frontend and writing user-facing text, adding a second language, or asked to \"make this translatable / use tokens / add i18n\". DO NOT TRIGGER for backend functions, error messages thrown from functions, or log output.'\ninstallGroups: [core]\n---\n\n# Pikku i18n (Paraglide JS)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Every user-facing string in a frontend is a message. Never hardcode display text — add a key to `messages/en.json` and render `m.the__key()`. This holds even when the app ships only English; the messages are the seam a second language slots into later.\n2. One `messages/<locale>.json` per language at the app root (NOT under `src/`), declared in `project.inlang/settings.json`. English (`en`) is `baseLocale` and the only locale until someone adds another.\n3. Messages compile to typed ESM functions in `src/paraglide/` (generated, self-gitignored — never edit or commit it). The Vite plugin compiles during `dev`/`build` with HMR on message edits; run the CLI compile only when you need `tsc` before Vite has ever run.\n4. Validate with the app's own `tsc` then its `build`. The deploy pipeline compiles Paraglide and runs each frontend's `tsc` before building it — an i18n mistake blocks the deploy.\n\n## The moving parts (starter-template layout)\n\n- `messages/en.json` — flat keys, `{param}` interpolation, inlang message-format:\n ```json\n {\n \"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n \"auth__login__title\": \"Sign in\",\n \"auth__login__description\": \"Welcome back to {name}.\"\n }\n ```\n Key convention: lower snake_case, `__` (double underscore) between namespace segments, `_` within a segment — `auth__login__title`, `common__email_placeholder`.\n- `project.inlang/settings.json` — `baseLocale`, `locales`, the `@inlang/plugin-message-format` module, `pathPattern: \"./messages/{locale}.json\"`.\n- `vite.config.ts` — `paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' })` from `@inlang/paraglide-js` (devDependency), FIRST in the plugins array.\n- `src/paraglide/` — compiled output (`messages.js`, `runtime.js`, per-locale `messages/*.js`). Generated; it writes its own `.gitignore`.\n- `src/i18n/config.ts` — locale plumbing, and the ONLY hand-written i18n module: `supportedLocales`/`defaultLocale` (re-exported from `../paraglide/runtime.js`), `detectLocale`, `localeDir` (RTL for ar/he/fa/ur), a reactive locale store (`overwriteGetLocale` bridged to `useSyncExternalStore`), `setActiveLocale`, `useLocale()`. This is not a wrapper over messages — Paraglide's `getLocale()` is a module global with no React reactivity, and this bridges it. Wire `overwriteGetLocale` or `m.*()` will resolve a different locale than the app thinks is active.\n- `tsconfig.json` — `\"allowJs\": true, \"checkJs\": false` so `tsc` can consume Paraglide's JSDoc-typed JS output.\n\n## Using messages in components\n\n```tsx\nimport { m } from '../paraglide/messages.js'\nimport { useLocale } from '@/i18n/config'\n\nfunction LoginPage() {\n useLocale() // subscribe: re-render m.*() when the locale switches\n return (\n <>\n <Title>{m.auth__login__title()}</Title>\n <Text>{m.auth__login__description({ name: m.app__name() })}</Text>\n </>\n )\n}\n```\n\n- Params: `{name}` in the JSON → `m.auth__login__description({ name })`. Params are typed per message.\n- Any component that renders `m.*()` calls `useLocale()` (bare call is enough); it also returns `{ locale, dir, setLocale }` for switchers.\n- Non-component helpers (formatters, status maps) call `m.some__key()` directly — the functions are plain ESM, no hook needed; the render-time subscription lives in the component that displays the result.\n- Locale switching: the root route persists to localStorage, sets `<html lang dir>` (`localeDir`), and calls `setActiveLocale` — in-SPA re-render, no page reload. Mirror `routes/__root.tsx` in the starter template.\n\n## Keys only known at runtime (enum labels, status maps)\n\nA DB value picking a label is the one case a generated message can't express.\nParaglide's README (§ \"What about dynamic or CMS-driven keys?\") is explicit: use\nan **explicit mapping from value to message function**. Key it on the enum type,\nnever `string`:\n\n```ts\nimport { m } from '../paraglide/messages.js'\n\nconst DOCUMENT_STATUS_LABEL: Record<DocumentStatus, () => string> = {\n completed: m.enum__document_status__completed,\n in_progress: m.enum__document_status__in_progress,\n required: m.enum__document_status__required,\n}\n\n// call site — no fallback, because there is no missing case\nDOCUMENT_STATUS_LABEL[status]()\n```\n\n`Record<DocumentStatus, …>` is exhaustive: add a value to the enum without a\nlabel and the build fails. That is the entire point.\n\n**Don't write these maps by hand.** `@pikku/paraglide` generates them from the\n`enum__<group>__<member>` keys in the catalog and types each one against the DB\nenum it mirrors, so a migration adding a status is a compile error rather than a\nmap someone forgot. Use the namespace above (singular `enum`, `__` between\nsegments) so the generator picks the group up, and read `pikku-paraglide` before\nadding one.\n\nDo NOT write `Record<string, () => string>` with a `?? status` fallback, and do\nNOT index the namespace with a computed key (`m[\\`enums__${name}__${value}\\`]`).\nBoth compile, both render the raw identifier to users when a label is missing,\nand both reintroduce exactly the silent-fallback failure Paraglide exists to\neliminate. If you find yourself writing a `resolveDynamicKey(key: string)`\nhelper, stop — that helper IS the bug.\n\n## Type safety — and why deploys block on i18n\n\nA message IS a function: a typo'd or deleted key (`m.auth__login__titel()`) is a missing export — a **TypeScript error**, not a silent runtime fallback string. Params are typed too. The deploy pipeline compiles Paraglide then runs each frontend's `tsc` (`\"tsc\": \"tsc --noEmit\"` script — keep it in every frontend's `package.json`) **before** building; a type error aborts the deploy. `vite build` does not type-check on its own, so this gate is the only thing standing between a broken message and production.\n\nThe gate catches _invalid_ messages but not _inlined_ strings. The `@pikku/mantine` `I18nNode` prop typing catches those: a raw string literal fails to compile on a gated prop, because `I18nString` is a branded type a bare `string` can't satisfy. Between the two, `tsc` is the whole safety net — there is no runtime fallback to inspect, by design.\n\n## Compile step\n\n- **Dev/build:** the Vite plugin compiles automatically; editing `messages/*.json` under a running dev server recompiles + HMRs.\n- **Standalone `tsc` before Vite has run** (fresh clone, CI):\n ```sh\n npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/paraglide\n ```\n This is exactly what the deploy CI does before the per-app `tsc`.\n\n## Adding a second language\n\n1. `messages/fr.json` mirroring `en.json`'s keys (translate the values, keep `{param}` names identical).\n2. Add `\"fr\"` to `locales` in `project.inlang/settings.json`.\n3. Recompile (restart/`vite dev` or the CLI compile). A locale file missing keys falls back to the base locale per message.\n4. Content is reachable via the `/<lang>` URL prefix (`detectLocale` already resolves it); the base locale needs no prefix. Expose the switcher via `useLocale().setLocale`.\n\n## i18n debug mode (find inlined strings)\n\n`tsc` catches invalid messages, and the `@pikku/mantine` gate catches raw strings on gated props — but neither sees a hardcoded string in plain JSX, an `aria-label`, `alt`, `document.title`, or anything passed to a non-Mantine component. Debug mode covers that gap: render every message as block glyphs (`█`), and whatever is still readable never went through a message.\n\n**Build it as a generated locale, never as a runtime wrapper.** Masked text is text, and rendering different text per locale is what Paraglide already does:\n\n1. A script generates `messages/zz.json` from `en.json`, replacing `\\S` with `█` while leaving `{placeholders}` intact (they are message inputs — mangling them changes the compiled signature). Run it before `paraglide-js compile`; gitignore the output.\n2. Add `\"zz\"` to `locales` in `project.inlang/settings.json`.\n3. Switch to it in the locale bridge:\n ```ts\n overwriteGetLocale(() => (isI18nDebug() ? 'zz' : activeLocale))\n ```\n\nKeep `zz` out of the app's own `supportedLocales` — that drives URL prefixes, hreflang and any backend `locale` param, none of which should see it.\n\nGenerate the catalogue in dev only. With `messages/zz.json` absent, Paraglide compiles `zz` to an alias of the base locale (`const zz_x = en_x` — one line per message, no duplicated strings), so a production bundle carries the locale at effectively zero cost.\n\nBoth the generator and the store bridge are being upstreamed (pikkujs/pikku#1036, #1035).\n\nThe wrapper alternative — a module that walks the namespace and pipes each message through a `mask()` — is what this replaces. It defeats tree-shaking (touching every export), adds a check on every call, and forces every component to import `m` from the wrapper instead of Paraglide.\n\n## What NOT to do\n\n- Don't hardcode display strings \"just for now\" — the message is the work.\n- Don't edit or commit anything under `src/paraglide/` — it's regenerated; change `messages/*.json` instead.\n- **Don't wrap `m`.** No re-export module, no branding layer, no resolver. Components import `m` from `../paraglide/messages.js` and call it. `@pikku/react`'s `I18nString` is declared as `string & { readonly __brand: 'LocalizedString' }` — deliberately identical to Paraglide's own `LocalizedString` — so `m.some__key()` satisfies the `@pikku/mantine` `I18nNode` gate natively. A wrapper adds nothing and costs per-message tree-shaking.\n\n `packages/console` is the one place in this repo that still wraps it, in `src/i18n/messages.ts`, to keep the debug mask (`█`) it carried over from i18next. That wrapper is a leftover, not a pattern — the generated-locale approach above is how a new app gets the same masking without touching every export. Don't copy it.\n\n The `mKey`/`mList` runtime resolvers that used to live beside it are **gone**, and must not come back. `mList` resolved indexed `prefix.0` keys that no longer exist; `mKey` took a computed string, which is exactly the type safety Paraglide exists to provide. Where a key really is dynamic, map the discriminant to a message *function* and call it — the map is type-checked, a string is not.\n- Don't re-resolve messages by string key or re-implement `{param}` interpolation. A key-string resolver turns a missing key back into silent runtime text, surrendering the type safety that is the entire reason to use Paraglide.\n- Don't reach for i18next/react-i18next or a runtime-fetch translation loader — Paraglide's compiled functions are the whole delivery mechanism.\n- Don't tokenize backend error messages or logs here — those are not frontend display strings.\n", "pikku-info/SKILL.md": "---\nname: pikku-info\ndescription: >-\n Discover what exists in a Pikku project — functions (with their transport, middleware and\n permissions), tags, middleware and permission definitions. Use when you need to understand the\n project structure, find existing functions, or check what middleware and permissions are\n defined. TRIGGER when: user asks \"what functions exist?\", \"show me the project structure\", \"list\n routes/middleware/permissions\", or needs to understand an existing Pikku codebase. DO NOT\n TRIGGER when: user is writing new code (use the specific wiring skill) or asking about Pikku\n concepts (use pikku-concepts).\ninstallGroups: [core]\nallowed-tools: Bash(yarn pikku info *)\nargument-hint: '[functions|tags|middleware|permissions] [--verbose] [--limit N]'\n---\n\n# Pikku Project Discovery\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nUse the `pikku info` CLI commands to inspect this Pikku project. Run the commands below and present the results to the user in a clear summary.\n\nThere are exactly four subcommands — `functions`, `tags`, `middleware`,\n`permissions`. Routes, channels, schedulers and queues are not separate\nsubcommands; they show up as the *transport* column of `info functions --verbose`.\n\n## Available Commands\n\n`--silent` suppresses the banner and the inspector's diagnostics, which is what\nyou want when parsing the table. It is read by the CLI but not declared as an\noption, so every run also prints `Warning: Unknown option: --silent (ignored)` —\nthe warning is wrong, the flag works. Ignore that one line.\n\nFor anything you intend to parse rather than read, prefer `--json` (alias\n`-j`, or `--output json`), which emits NDJSON instead of a formatted table.\n\n### Functions\n\nList all registered pikku functions:\n\n```bash\nyarn pikku info functions --silent\n```\n\nFor full details including transport type (http/channel/scheduler/queue/workflow/mcp/cli/trigger), middleware, permissions, and source file:\n\n```bash\nyarn pikku info functions --verbose --silent\n```\n\n### Tags\n\nList all tags with counts of associated functions and middleware:\n\n```bash\nyarn pikku info tags --silent\n```\n\nFor full names instead of counts:\n\n```bash\nyarn pikku info tags --verbose --silent\n```\n\n### Middleware\n\nList all middleware definitions:\n\n```bash\nyarn pikku info middleware --silent\n```\n\nFor full details including source file, required services, and description:\n\n```bash\nyarn pikku info middleware --verbose --silent\n```\n\n### Permissions\n\nList all permission definitions:\n\n```bash\nyarn pikku info permissions --silent\n```\n\nFor full details including source file, required services, and description:\n\n```bash\nyarn pikku info permissions --verbose --silent\n```\n\n## Instructions\n\n1. If the user specifies a subcommand (e.g., `/pikku-info functions`), run only that command.\n2. If no subcommand is specified, run all four commands to give a complete project overview.\n3. Always use `--silent` to suppress the Pikku banner and inspector logs, and disregard the spurious \"Unknown option\" warning it prints.\n4. Use `--verbose` when the user asks for details, file paths, or \"more info\". On `tags` it swaps counts for names; elsewhere it adds columns.\n5. Use `--limit N` to control output size (default is 50 rows) — the footer tells you how many were withheld.\n6. After running the commands, summarize the findings concisely:\n - Total count of functions, tags, middleware, and permissions\n - Notable patterns (e.g., which transport types are in use, which tags group the most functions)\n - Any functions without tags or transport types (potential issues)\n", "pikku-jose/SKILL.md": "---\nname: pikku-jose\ndescription: >-\n Use when setting up JWT authentication with the jose library in a Pikku app. Covers\n JoseJWTService constructor, secret rotation, token encoding/decoding/verification. TRIGGER when:\n code uses JoseJWTService, user asks about JWT setup, token signing, token verification, or\n @pikku/jose. DO NOT TRIGGER when: user asks about session middleware (use pikku-security) or\n general service setup (use pikku-services).\n---\n\n# Pikku Jose (JWT Service)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/jose` provides JWT signing, verification, and decoding using the [jose](https://github.com/panva/jose) library. Implements the `JWTService` interface from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/jose\n```\n\n## API Reference\n\n### `JoseJWTService`\n\n```typescript\nimport { JoseJWTService } from '@pikku/jose'\n\nconst jwt = new JoseJWTService(\n getSecrets: () => Promise<Array<{ id: string; value: string }>>,\n logger?: Logger\n)\n\nawait jwt.init()\n```\n\n**Constructor Parameters:**\n\n- `getSecrets` — Async function returning an array of `{ id, value }` key pairs. The **first** entry signs; every entry can verify.\n- `logger` — Optional logger instance.\n\n**Methods:**\n\n- `init(): Promise<void>` — Fetch and cache secrets. Call at startup.\n- `encode<T>(expiresIn: RelativeTimeInput, payload: T): Promise<string>` — Create a signed JWT, stamping the signing key's `id` as the token's `kid` header.\n- `decode<T>(token: string): Promise<T>` — **Verifies** the signature and expiry, then returns the payload.\n- `verify(token: string): Promise<void>` — The same check, discarding the payload.\n\n`decode` is not an unchecked read: both methods run `jose.jwtVerify` and both\nthrow on a bad signature or an expired token. There is no way to inspect an\nuntrusted payload through this service — reach for `jose.decodeJwt` directly if\nyou genuinely need that, and treat the result as unauthenticated input.\n\nTokens are signed **HS256** with a symmetric secret. The algorithm is fixed and\npinned on verification, so a token arriving with any other `alg` is rejected —\nbut it also means this service has no asymmetric (RS256/ES256) mode.\n\n`init()` is not strictly required: `encode` calls it lazily on first use. Call it\nat startup anyway so a missing or unreachable secret fails at boot rather than\non the first request that needs a token.\n\n## Usage Patterns\n\n### Basic Setup\n\n```typescript\nimport { JoseJWTService } from '@pikku/jose'\n\nconst jwt = new JoseJWTService(\n async () => [{ id: 'key-1', value: await secrets.getSecret('JWT_SECRET') }],\n logger\n)\nawait jwt.init()\n```\n\nA signing key is a secret, so it comes from the secrets service rather than\n`process.env` — and because `getSecrets` is a function called on demand, reading\nit there (not once at construction) is what makes the re-init-on-unknown-kid path\nabove actually see a rotated key. See `pikku-config`.\n\n### Secret Rotation\n\nSupply multiple keys. The first signs; the rest stay available for verification:\n\n```typescript\nconst jwt = new JoseJWTService(async () => [\n { id: 'key-2', value: NEW_SECRET }, // signs with this\n { id: 'key-1', value: OLD_SECRET }, // still verifies tokens signed with this\n])\n```\n\nVerification resolves the key by the token's `kid` header rather than trying each\nsecret in turn — which is why `encode` stamps the signing key's `id` there, and\nwhy the ids must stay stable across a rotation. Keep an id in the list for as\nlong as tokens bearing it can still be in flight.\n\nWhen a `kid` isn't in the cache, the service re-runs `getSecrets()` once before\ngiving up with `Missing secret for id: <kid>`. That is what lets a running server\npick up a newly added key without a restart, provided `getSecrets` reads from\nsomething live (a secret store) rather than a value captured at boot. A token\nwith no `kid` at all falls back to the current signing key.\n\n### With Pikku Services\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const jwt = new JoseJWTService(\n async () => [{ id: 'my-key', value: config.jwtSecret }],\n logger\n )\n await jwt.init()\n return { config, logger, jwt }\n})\n```\n\n### Encoding & Verifying Tokens\n\n```typescript\nconst token = await jwt.encode('1h', { userId: 'abc', role: 'admin' })\n\nawait jwt.verify(token) // throws if invalid/expired\n\nconst payload = await jwt.decode<{ userId: string; role: string }>(token)\n```\n", "pikku-knowledge/SKILL.md": "---\nname: pikku-knowledge\ndescription: >-\n Use when writing, reading, reorganising or validating a project's knowledge/ directory — the\n notes that say what the app is, in the language its users use. Covers the Open Knowledge Format\n note (path-as-identity markdown, YAML frontmatter, only `type` required), the sections of the\n app-project profile (slices, entities, decisions, questions, wishlist) and the one question each\n answers, slice status/entities/gherkin rules, the `resource:` URI scheme tying a note to the\n code it is about, the shapes that are NOT a knowledge base, and the `pikku knowledge\n validate|index` commands. TRIGGER when: user asks to write down a decision, requirement, entity\n or open question; asks what the app does or is; asks about knowledge/, notes, slices,\n an index.md, or a diagram, callout or decision block; or hands over a product\n brief to record. DO NOT TRIGGER when: user asks what\n functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a\n note), or to write a scenario test (use pikku-scenario).\ninstallGroups: [core]\n---\n\n# Pikku Knowledge\n\nThe knowledge base is `knowledge/` at the repo root: markdown notes about **what the app is**, written for whoever picks the project up next — human or agent.\n\nNot to be confused with `.knowledge/` — the dot-prefixed JSON blueprint that `pikku-software-archaeology` extracts from a legacy repo. Different directory, different format, different purpose.\n\n## Agent Operating Procedure\n\n1. **Read `knowledge/index.md` first**, then the section index for whatever you are about to touch. It is the cheapest way to learn what the app already claims about itself.\n2. Before writing a note, ask whether `pikku meta` already answers it. If it does, do not write the note — see _What never goes in a note_.\n3. Write the note in the section that answers its question. Create the section's `index.md` in the same turn you create the section.\n4. Add a `resource:` only if you can name a real id. A wrong one is worse than none.\n5. Run `pikku knowledge validate`. Fix what it reports.\n6. Run `pikku knowledge index` so each section lists what is actually in it.\n\n## The governing rule\n\n**Record only what pikku cannot tell you.**\n\nPikku already knows every function, route, schema, table, column, queue, cron, channel and permission — `pikku meta` prints them, and the generated meta is the truth. A note that lists tables or routes is a copy that starts drifting the moment somebody edits the code, and it drifts _while looking authoritative_, which is worse than silence.\n\nWhat a note is for is the part no generator can derive: what a thing means, why a rule was chosen, what it rules out, who asked for it, and what is still unanswered.\n\n## The note\n\nA note is a markdown file whose **path is its identity** — moving it renames it. It carries YAML frontmatter and a body:\n\n```markdown\n---\ntype: decision\ntitle: Revocation ends a grant\ndescription: A revoked grant stops working immediately, everywhere.\nresource: func:revokeGrant, table:grant\ntags: [sharing, access]\n---\n\n# Revocation ends a grant\n\nWhen an owner revokes a grant, the person loses access on their next request — no\ngrace period and no scheduled cleanup.\n\nThis rules out a \"revoked but valid until midnight\" state, which we considered\nfor shared days and rejected: two people disagreeing about who can see today is\nworse than one of them losing access mid-session.\n```\n\nFrontmatter fields:\n\n| Field | Meaning |\n| ------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `type` | **The only required field.** `slice`, `entity`, `decision`, `note`, `overview`. Lowercase — gates compare it literally. |\n| `title` | What to call the note in a listing. Falls back to the first heading, then the filename. |\n| `description` | One line, used as the note's subtitle in a section index. |\n| `resource` | Comma-separated `<kind>:<id>` URIs — the code this note is about. See below. |\n| `tags` | Flow list (`[a, b]`) or a `- item` block; both are read. |\n| `timestamp` | When it was written, if it matters. |\n\n`index.md` and `log.md` are **reserved**: an `index.md` maps a directory, a `log.md` is an append-only record. Neither is ever listed as a note by an index.\n\nPlain markdown links between notes — `[revocation](../decisions/revocation-ends-a-grant.md)` — are what make the base a graph. A link to a note that does not exist yet is legal: it marks something worth writing, not an error.\n\n## The layout\n\n```\nknowledge/\n index.md # type: overview — the map\n slices/\n index.md\n 01-the-daily-entry.md # type: slice\n entities/\n index.md\n entry.md # type: entity\n decisions/\n index.md\n revocation-ends-a-grant.md # type: decision\n security/\n index.md\n one-account-one-person.md\n questions/\n index.md\n who-owns-a-shared-day.md # type: note\n wishlist/\n index.md\n export-to-a-calendar.md # type: note\n```\n\nEach section answers exactly one question, which is what lets a reader find a note without an index of indexes:\n\n| Section | The question it answers |\n| --------------------- | ------------------------------------------------------------------ |\n| `slices/` | What is one buildable piece of this app, and what proves it works? |\n| `entities/` | What is this thing, in the words users use for it? |\n| `decisions/` | What was chosen, and what does that rule out? |\n| `decisions/security/` | Who may do what? |\n| `questions/` | What has been asked and not yet answered? |\n| `wishlist/` | What does somebody want that nobody has asked to be built? |\n\n**Create a section the turn you have a note for it** — never a scaffold of empty directories, and never a section without its own `index.md`. A section index says in one line what belongs in it; that sentence is the reason the file exists, so `pikku knowledge index` writes only the note listing and leaves your prose alone.\n\n## Slices\n\nA slice is the one note type that is a piece of _work_ rather than a fact, so it alone carries state and size:\n\n````markdown\n---\ntype: slice\ntitle: The daily entry\ndescription: An owner writes one entry per day, and sees it on the day.\nstatus: proposed\nentities: entry, day\nresource: func:createEntry\n---\n\n# The daily entry\n\nAn owner writes at most one entry per day. Writing again replaces it.\n\n```gherkin\nGiven 'owner' has no entry for today\nWhen 'owner' writes one\nThen it appears on today's day\nAnd writing again replaces it rather than adding a second\n```\n````\n\n- **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.\n- **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.\n- **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.\n\n## Showing it\n\nA note is markdown, and four kinds of block are **drawn** rather than printed. Every one of them degrades to something readable — a diagram falls back to its source, a callout to a blockquote, a decision to a code block — so writing one costs nothing where it is not rendered.\n\nNone of this changes the governing rule. A diagram of the schema is still a copy of `pikku meta` that drifts, and it drifts while looking more authoritative than prose would. These are for the part no generator can derive.\n\n**```mermaid — when the relationship is the point.** Prose is bad at graphs: \"an entry belongs to a day, a day belongs to an owner, and a grant lets another owner read a day\" is a sentence a reader has to re-read twice and draw themselves. Reach for one when a note is about how several things relate, an order of steps across time, or a state machine. Do not draw one thing, or two things and an arrow — that is a sentence.\n\n````markdown\n```mermaid\nflowchart LR\n owner -->|writes| entry\n entry -->|belongs to| day\n owner -->|grants read on| day\n```\n````\n\n**`> [!NOTE]` — when a line must survive skimming.** Five kinds: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Use one for the thing a reader who skips the paragraph must still not miss — a trap, a constraint that is easy to violate, an assumption the rest of the note rests on. Two callouts in a note is normal; six means the note has no prose left and nothing stands out.\n\n```markdown\n> [!WARNING]\n> A grant is checked on every request, not cached. A permission change is\n> immediate everywhere, and there is no invalidation step to forget.\n```\n\n**```decision — the answer a decision note owes.** `decisions/` answers \"what was chosen, and what does that rule out?\", and the second half is the half that gets dropped. The fence makes it checkable: `pikku knowledge validate` warns when a fence says what was chosen and never says what it closes off.\n\n````markdown\n```decision\nchosen: A revoked grant stops working immediately, everywhere.\nrules-out:\n - A \"revoked but valid until midnight\" state\n - A scheduled cleanup job\nbecause: Two people disagreeing about who can see today is worse than one of\n them losing access mid-session.\n```\n````\n\nIt is a **summary, not the note** — the argument continues in prose underneath. `rules-out:` takes one line or a `- item` block, and any value too long for one line wraps onto indented lines under it, as `because:` does above. A decision genuinely argued in prose needs no fence, and validate never asks for one; what it does ask is that a fence you did write is complete.\n\n**Fences of any other language are code** — highlighted and copyable, which is right for a snippet and wrong for a scenario or a decision, so do not put either in a bare fence.\n\n## `resource:` — tying a note to the code\n\n`resource:` names the code a note is about, as one or more `<kind>:<id>` URIs, comma-separated.\n\n**Every kind resolves.** That is the whole design: a kind that cannot be checked lets notes accumulate references nothing validates, and the graph rots into fiction exactly where it looks most authoritative.\n\n| Kind | An id is | Where it resolves |\n| ----------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |\n| `func:` | a function id | generated function meta |\n| `workflow:` | a workflow name | generated workflow meta |\n| `schema:` | a schema name | generated schemas |\n| `http:` | a route, `method:route`, or the function behind it | generated http wirings |\n| `queue:` | a queue name | generated queue wirings |\n| `cron:` | a scheduled task name | generated scheduler wirings |\n| `channel:` | a channel name | generated channel meta |\n| `table:` | a table name | the generated db schema |\n| `addon:` | `@pikku/addon-x` or bare `x` | the manifests that declare the dependency |\n| `scope:` | a scope name | the `scopes:` a function gates itself with, plus the scopes a `defineSystemRole()` confers |\n| `persona:` | a persona name | `definePersonas()` |\n\nIds are case-sensitive: `createEntry` is not `createentry`.\n\nThe check **fails closed on drift and open on ignorance**. An id missing from a kind that resolved is an error — the code was renamed or deleted under the note. A kind with no generated meta at all is skipped, so a project without queues is never told its queue references are broken.\n\nThere is no kind for a service, a middleware or a component. Say it in prose instead.\n\n## What never goes in a note\n\nThese are all things that exist somewhere better, so a note is always the copy that drifts:\n\n| Do not write | Because it lives in |\n| ----------------------------------- | ---------------------------------------------------------- |\n| a `personas/` section | `definePersonas()` in the project's own code |\n| a `scenarios/` section | the gherkin block inside the slice it belongs to |\n| a `permissions/` section | a decision note under `decisions/security/` |\n| a list of tables, columns or routes | `pikku meta` — the generated schema _is_ the schema |\n| a changelog | `CHANGELOG.md` at the repo root |\n| **secrets or credentials** | a secrets service. Never here — `knowledge/` is committed. |\n\nAnd two shapes that look like a knowledge base but are not:\n\n- **A flat `product.md` / `glossary.md` / `technology.md` at the root of `knowledge/`.** That is one long document: nothing can link into part of it, and no gate can read it. Split it into notes in the sections that answer its questions.\n- **A directory tree with no notes in it.** Sections exist because there is something to put in them.\n\n## The commands\n\n```bash\npikku knowledge validate # check the base against this profile\npikku knowledge index # refresh every index.md\npikku knowledge index --check # report stale indexes without writing (CI gate)\n```\n\n`validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, `decision` fences that state no `chosen:` or rule nothing out, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.\n\n`index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.\n\n## Profiles built on this one\n\nOKF permits frontmatter fields a reader does not know, and the parser ignores them rather than failing. That is the extension point: a tool layered on Pikku can add its own sections and fields on top of everything above without forking the format.\n\nFabric is the one that exists. It adds `decisions/design/` — rules about how the app looks and behaves — and a `design:` field on a slice pointing at the design options it was built from. Both are Fabric's to validate; `pikku knowledge validate` passes them through untouched. Everything else in this skill is the same in both.\n", "pikku-kysely/SKILL.md": "---\nname: pikku-kysely\ndescription: >-\n Use when WRITING KYSELY QUERIES (select/join/aggregate/insert/update/delete) inside a Pikku\n function body, or when setting up SQL database services with Kysely. Covers the query builder\n API (joins, aggregates + groupBy/having, returning, sql template, expression builder, $if,\n transactions, jsonArrayFrom relation helpers) AND @pikku/kysely service setup (channel stores,\n workflow services, secret services, AI storage, deployment services). TRIGGER when: writing any\n non-trivial kysely query (a join, an aggregate/count/sum, groupBy, subquery, transaction, or\n conditional query), the injected `kysely` service is used in a function body, or code uses\n PikkuKysely, KyselyChannelStore, KyselyWorkflowService, KyselySecretService, or the user asks\n about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB (use pikku-mongodb) or\n Redis (use pikku-redis).\ninstallGroups: [core]\n---\n\n# Pikku Kysely (SQL Database Services)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## Writing Queries — the Kysely query builder\n\nIn a Pikku function body the injected `kysely` IS the `Kysely<DB>` instance — query it directly. Every connection factory below wires the **CamelCasePlugin** by default, so you write **camelCase everywhere in TS** (columns, aliases) and raw **snake_case ONLY inside a `` sql`` `` literal**. If a project opted out (`createNodeSqliteKysely({ camelCase: false })`), that inverts — check how the instance was built before assuming. Kysely is a query builder, NOT an ORM — there are no relations; shape nested data with the JSON helpers below. Never hand-roll SQL strings; never annotate the return type (in Pikku the output zod schema IS the type).\n\n```typescript\nimport { sql } from 'kysely'\n// Relation helpers are ENGINE-SPECIFIC — import the matching path:\nimport { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite' // SQLite / libSQL\n// import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres' // Postgres\n```\n\n```typescript\n// SELECT + where/orderBy/limit/offset. Terminals: .execute() | .executeTakeFirst()\n// | .executeTakeFirstOrThrow(() => new NotFoundError()) — pass an error factory.\nconst rows = await kysely.selectFrom('item')\n .select(['id', 'name', 'quantity'])\n .where('warehouseId', '=', warehouseId)\n .orderBy('name').limit(50).execute()\n\n// JOINS + aliased selects (qualify columns once a join exists)\nawait kysely.selectFrom('stock')\n .innerJoin('item', 'item.id', 'stock.itemId')\n .leftJoin('bin as b', 'b.id', 'stock.binId')\n .select(['stock.id', 'item.name as itemName', 'b.code as binCode'])\n .execute()\n\n// AGGREGATES via the fn helper + groupBy/having. eb.fn.count returns string|number —\n// cast if you need a JS number (SQLite: CAST(... AS INTEGER)).\nawait kysely.selectFrom('stock')\n .select((eb) => ['itemId', eb.fn.sum<number>('quantity').as('onHand')])\n .groupBy('itemId')\n .having((eb) => eb.fn.sum('quantity'), '<', 10) // low-stock\n .execute()\n\n// INSERT + RETURNING (one round-trip; works on SQLite & Postgres)\nconst created = await kysely.insertInto('item')\n .values({ name: input.name, warehouseId })\n .returning(['id', 'name']).executeTakeFirstOrThrow()\n\n// UPDATE + RETURNING, DELETE\nawait kysely.updateTable('item').set({ quantity: input.quantity })\n .where('id', '=', input.id).returning(['id', 'quantity']).executeTakeFirstOrThrow()\nawait kysely.deleteFrom('item').where('id', '=', input.id).execute()\n\n// EXPRESSION BUILDER for and/or; $if for conditional building; sql for raw fragments\nawait kysely.selectFrom('item')\n .selectAll()\n .where((eb) => eb.or([eb('quantity', '=', 0), eb('discontinued', '=', true)]))\n .$if(!!input.search, (qb) => qb.where('name', 'like', `%${input.search}%`))\n .select(sql<number>`quantity * unit_cost`.as('value')) // snake_case ok inside sql``\n .execute()\n\n// NESTED DATA (no relations) — jsonObjectFrom (one) / jsonArrayFrom (many)\nawait kysely.selectFrom('warehouse')\n .select((eb) => ['warehouse.id', 'warehouse.name',\n jsonArrayFrom(eb.selectFrom('bin').select(['bin.id', 'bin.code'])\n .whereRef('bin.warehouseId', '=', 'warehouse.id')).as('bins')])\n .execute()\n\n// TRANSACTION — multi-write atomicity. Use trx (not kysely) inside.\nawait kysely.transaction().execute(async (trx) => {\n await trx.updateTable('stock').set({ quantity: 0 }).where('itemId', '=', id).execute()\n await trx.insertInto('stockMove').values({ itemId: id, delta: -qty }).execute()\n})\n```\n\nPikku provides SQL database services through six packages:\n\n- `@pikku/kysely` — Base service implementations (database-agnostic), the serialize plugins, `createAuditedKysely` and the `pikkuSchemas` helpers\n- `@pikku/kysely-postgres` — PostgreSQL-specific implementations + the `PikkuKysely` connection wrapper and `PgEventHubService` (LISTEN/NOTIFY-backed)\n- `@pikku/kysely-mysql` — MySQL-specific implementations\n- `@pikku/kysely-sqlite` — SQLite-specific implementations, `createSQLiteKysely`, and the `LibsqlWebDialect`\n- `@pikku/kysely-node-sqlite` — `createNodeSqliteKysely` over `node:sqlite`, plus user-defined SQL functions and the coercion plugin\n- `@pikku/kysely-bun-sqlite` — the same over `bun:sqlite`\n\nThe last two are runtime adapters rather than service sets: they build the\n`Kysely<DB>` you inject into functions, while the dialect packages above supply\nPikku's own stores. They differ in one place — `bun:sqlite` cannot register\nscalar functions, so `createBunSqliteKysely` throws if you pass `functions`.\n\nAll implement standard Pikku interfaces from `@pikku/core`.\n\n## Installation\n\n```bash\n# Pick your database\nyarn add @pikku/kysely @pikku/kysely-postgres # PostgreSQL\nyarn add @pikku/kysely @pikku/kysely-mysql # MySQL\nyarn add @pikku/kysely @pikku/kysely-sqlite # SQLite (stores)\nyarn add @pikku/kysely-node-sqlite # SQLite on Node\nyarn add @pikku/kysely-bun-sqlite # SQLite on Bun\n```\n\n## API Reference\n\n### PostgreSQL Connection — `PikkuKysely`\n\n```typescript\nimport { PikkuKysely } from '@pikku/kysely-postgres'\n\nconst db = new PikkuKysely<DB>(\n logger: Logger,\n connectionOrConfig: postgres.Sql | postgres.Options | string,\n defaultSchemaName?: string,\n poolConfig?: PostgresConfig // maxPool, connectTimeout, idleTimeout, maxLifetime, prepare, statementTimeout\n)\n\nawait db.init()\ndb.kysely // Kysely<DB> instance for queries\nawait db.close()\n```\n\nIt builds a postgres.js-backed Kysely with the CamelCasePlugin. Pass an existing\n`postgres.Sql` when something else owns the pool — the wrapper then leaves it\nopen on `close()`. `poolConfig` keys are only forwarded when set, so postgres.js\nkeeps its own defaults for the rest, and it is ignored entirely when you hand in\nan already-constructed connection.\n\n### SQLite factories\n\n```typescript\nimport { createNodeSqliteKysely } from '@pikku/kysely-node-sqlite'\n\n// Your application DB — CamelCasePlugin on by default\nconst kysely = createNodeSqliteKysely<DB>({\n filename: 'app.db', // or ':memory:'\n camelCase: true,\n plugins: [], // layered on top\n functions: {}, // scalar UDFs, registered as deterministic (Node only)\n})\n```\n\n```typescript\nimport { createSQLiteKysely } from '@pikku/kysely-sqlite'\n\n// Pikku's own tables — returns Kysely<KyselyPikkuDB>, not your DB\nconst pikkuDb = createSQLiteKysely(database: SqliteDatabase | (() => Promise<SqliteDatabase>))\n```\n\nThese two are not interchangeable. `createSQLiteKysely` is typed to\n`KyselyPikkuDB` and wires the `SerializePlugin` (JSON columns in and out) rather\nthan the CamelCasePlugin, because it exists to back the stores below. Reach for\n`createNodeSqliteKysely` / `createBunSqliteKysely` for the instance your\nfunctions query.\n\n### Available Services\n\nEach database variant exports these services with a prefix (`Pg`, `MySQL`, `SQLite`, or base `Kysely`):\n\n| Service | Interface | Purpose |\n| --------------------- | ------------------------------------- | ---------------------------------------------- |\n| `*ChannelStore` | `ChannelStore` | WebSocket channel state persistence |\n| `*EventHubStore` | `EventHubStore` | Event hub state persistence |\n| `*WorkflowService` | `PikkuWorkflowService` | Workflow definition storage |\n| `*WorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |\n| `*DeploymentService` | `DeploymentService` | Deployment state management |\n| `*AIStorageService` | `AIStorageService, AIRunStateService` | AI conversation/run storage |\n| `*AgentRunService` | `AgentRunService` | Agent execution tracking |\n| `*SecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |\n\nA handful more live only on the base package — there is no `Pg`/`MySQL`/`SQLite`\nvariant to reach for, you import them from `@pikku/kysely` whatever the engine:\n\n| Service | Purpose |\n| -------------------------- | --------------------------------------------- |\n| `KyselySessionStore` | Persisted user sessions |\n| `KyselyScopeService` | Scope and role storage |\n| `KyselyWebhookService` | Webhook registrations and deliveries |\n| `KyselyCredentialService` | Encrypted third-party credentials |\n| `KyselyAIRunStateService` | AI run state (also implemented by AIStorage) |\n| `KyselyWorkflowMirror` | Mirrors workflow runs into queryable tables |\n| `KyselyAuditService` | Durable audit sink (see `pikku-audit`) |\n\nAll services take a `Kysely<KyselyPikkuDB>` instance in their constructor and have an `init()` method that creates tables if needed.\n\n### Secret Service\n\nEnvelope encryption: each secret gets its own DEK, wrapped by a KEK derived from\n`key` plus a stored per-version salt. Keeping `previousKey` around is what makes\nrotation possible — `rotateKEK` re-wraps every secret from the old key to the\ncurrent one and returns the new version, and it throws if no `previousKey` is\nconfigured.\n\n```typescript\nimport { PgKyselySecretService } from '@pikku/kysely-postgres'\n\nconst secrets = new PgKyselySecretService(db.kysely, {\n key: 'your-key-encryption-passphrase',\n keyVersion: 2, // defaults to 1\n previousKey: 'the-passphrase-you-are-rotating-away-from',\n audit: true, // log write/delete/rotate through the audit sink\n auditReads: false, // reads too — noisy, off by default\n})\nawait secrets.init()\n\nawait secrets.setSecret('api-key', { key: 'sk-...' })\nconst secret = await secrets.getSecret<{ key: string }>('api-key')\nawait secrets.hasSecret('api-key')\nawait secrets.deleteSecret('api-key')\nconst newVersion = await secrets.rotateKEK()\n```\n\n`getSecret` hands back a `SecretValue<T>`, not the bare value — it serializes as\n`[secret]` until something reveals it, which is what stops a secret drifting into\na log line or an audit row. See `pikku-config` for the reveal rules.\n\n## Usage Patterns\n\n### PostgreSQL Setup\n\n```typescript\nimport {\n PikkuKysely,\n PgKyselyChannelStore,\n PgKyselyWorkflowService,\n} from '@pikku/kysely-postgres'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const db = new PikkuKysely(logger, config.databaseUrl)\n await db.init()\n\n const channelStore = new PgKyselyChannelStore(db.kysely)\n await channelStore.init()\n\n const workflowService = new PgKyselyWorkflowService(db.kysely)\n await workflowService.init()\n\n return { config, logger, database: db, channelStore, workflowService }\n})\n```\n\n### SQLite Setup\n\n```typescript\nimport {\n createSQLiteKysely,\n SQLiteKyselyChannelStore,\n} from '@pikku/kysely-sqlite'\nimport Database from 'better-sqlite3'\n\nconst kysely = createSQLiteKysely(new Database('app.db'))\nconst channelStore = new SQLiteKyselyChannelStore(kysely)\nawait channelStore.init()\n```\n\n### MySQL Setup\n\n```typescript\nimport { MySQLKyselyWorkflowService } from '@pikku/kysely-mysql'\n\nconst workflowService = new MySQLKyselyWorkflowService(kyselyInstance)\nawait workflowService.init()\n```\n", "pikku-machine-auth/SKILL.md": "---\nname: pikku-machine-auth\ndescription: >-\n Use when authenticating a CLI/agent/service against a Pikku server, adding machine-to-machine\n (M2M) auth, issuing scoped API keys for sandboxes/agents/workers, or wiring better-auth sessions\n into Pikku middleware. Covers `pikku login` (device-authorization), the better-auth API Key\n plugin, machine identities, and `betterAuthSession` with the api-key branch. TRIGGER when: user\n asks about CLI login, `pikku login`, machine agents, service-to-service auth, API keys, client\n credentials, sandbox/worker tokens, or resolving a better-auth session in a Pikku function. DO\n NOT TRIGGER when: user asks about end-user HTTP session/cookie auth only (use pikku-http + the\n app betterAuth config) or about WebSocket channel mechanics (use pikku-websocket).\n---\n\n# Pikku Machine Auth\n\nUnified authentication for humans **and** machines against a Pikku + better-auth\nserver. Two paths, two headers, one resolver:\n\n| Caller | Credential | Header | Obtained by |\n|---|---|---|---|\n| **Human** (CLI, dev) | better-auth session token | `Authorization: Bearer <token>` | `pikku login` (device flow) → `~/.pikku/session.json` |\n| **Machine** (agent, sandbox, worker) | scoped API key | `x-api-key: <key>` | `createApiKey` (server-side, at provision/spawn) |\n\nBoth resolve to a Pikku `UserSession` through one middleware:\n`betterAuthSession({ mapSession, apiKey: { mapKey } })`.\n\n> The literal OAuth `client_credentials` grant is **not** implemented in\n> better-auth's oidc-provider. The API Key plugin gives the same capability (a\n> baked secret a service presents for scoped access), not the wire protocol.\n\n## Agent Operating Procedure\n\n1. Discover before editing — inspect the app's `betterAuth({ plugins: [...] })`\n config and existing middleware wiring before adding anything.\n2. Server changes go in the auth factory + a middleware wiring file; never put\n auth checks in a function body (use `permissions`).\n3. The API Key plugin contributes an `apikey` table — add the matching SQL\n migration and regenerate DB types before relying on it.\n4. Validate with the narrowest command, then `pikku all`.\n\n## Human path — `pikku login`\n\n```bash\npikku login --url https://app.example.com # device-authorization flow\npikku whoami # show current session + expiry\npikku logout # remove stored session\n```\n\n`pikku login` runs the RFC 8628 device flow: it requests a code, opens the\nbrowser to the verification URL, polls until you approve, then stores the\nsession token (keyed by base URL) at `~/.pikku/session.json` with its expiry.\n\n**Server requirement** — enable the `deviceAuthorization` and `bearer` plugins:\n\n```typescript\nimport { deviceAuthorization, bearer } from 'better-auth/plugins'\n\nbetterAuth({\n // ...\n plugins: [\n deviceAuthorization({ expiresIn: '5min', interval: '5s', schema: {} }),\n bearer(), // lets `Authorization: Bearer <session-token>` resolve a session\n ],\n})\n```\n\nThe browser approval is two steps the user's browser does automatically:\n`GET /auth/device?user_code=XXXX` (claims the code while signed in) then\n`POST /auth/device/approve`. The CLI only requests the code and polls\n`POST /auth/device/token`.\n\n## Machine path — API keys\n\nInstall the plugin (separate official package) and enable it:\n\n```bash\nyarn add @better-auth/api-key # peer: better-auth ^1.6.19\n```\n\n```typescript\nimport { apiKey } from '@better-auth/api-key'\n\nbetterAuth({\n plugins: [\n apiKey({\n enableMetadata: true, // REQUIRED to store scope on the key\n enableSessionForAPIKeys: true, // lets a key resolve via getSession too\n }),\n ],\n})\n```\n\n### Identity model\n\nA **machine is an API key, not a throwaway user.** Keys are owned by a small set\nof stable **service-user** identities you provision once (e.g. `orchestrator`,\n`machine-agent`, `builder`, `sandbox-runtime`). Per-machine scope rides on the\nkey's `metadata`/`permissions`. A key requires a real owning user row — minting\none for a non-existent `userId` is created but will not resolve.\n\n### Mint a scoped key (server-side, at spawn/provision)\n\n```typescript\n// `auth` is the better-auth instance (injected service)\nconst { key } = await auth.api.createApiKey({\n body: {\n userId: sandboxRuntimeUserId, // a stable service user\n name: `sandbox:${sandboxId}`,\n expiresIn: 60 * 60, // seconds\n metadata: { sandboxId }, // keep only STABLE ids here\n permissions: { sandbox: ['read', 'write'] },\n },\n})\n// inject `key` into the machine's env; it sends it as `x-api-key`.\n```\n\nRotate by minting a new key and expiring/deleting the old (`deleteApiKey`);\nmultiple active keys per identity allow zero-downtime rotation.\n\n### Resolve scope — `verifyApiKey`, not `getSession`\n\n`getSession(x-api-key)` returns only a bare mock session **without** the\nmetadata. Scope must come from `verifyApiKey`, which returns\n`{ valid, key: { userId, metadata, permissions } }`. The\n`betterAuthSession` api-key branch does this for you:\n\n```typescript\nimport { betterAuthSession } from '@pikku/better-auth'\nimport { addHTTPMiddleware } from '@pikku/core/http'\n\naddHTTPMiddleware([\n betterAuthSession({\n // human path: getSession result -> app session\n mapSession: ({ user }) => ({ userId: user.id }),\n // machine path: verified key -> app session. `services` lets you resolve\n // CURRENT scope (e.g. look up the owning row) instead of trusting only the\n // baked metadata.\n apiKey: {\n header: 'x-api-key', // default\n mapKey: async (key, services) => {\n const sandboxId = key.metadata?.sandboxId\n if (!sandboxId) return null // reject\n const row = await services.kysely\n .selectFrom('sandboxInstance')\n .innerJoin('sandbox', 'sandbox.id', 'sandboxInstance.sandboxId')\n .select(['sandbox.orgId', 'sandbox.projectId'])\n .where('sandboxInstance.sandboxId', '=', sandboxId)\n .where('sandboxInstance.stoppedAt', 'is', null)\n .executeTakeFirst()\n if (!row) return null\n return { userId: sandboxId, orgId: row.orgId, role: 'sandbox' }\n },\n },\n }),\n])\n```\n\nWhen the api-key header is present it is authoritative — the middleware never\nfalls through to `getSession` (a bare mock session would shadow the scoped one).\nWhen it is absent, the human `getSession` path runs as normal. Either way the\nmiddleware bails out entirely if a session is already set, and it checks the\n*live* session rather than the wire's construction-time snapshot, so it can't\nclobber one an earlier middleware resolved.\n\n### Restricting a key below its owner\n\nSet `scopes` on the session `mapKey` returns and that set is **authoritative** —\nincluding an empty one. It is never widened back out to everything the owning\nservice user holds:\n\n```typescript\nmapKey: async (key) => ({\n userId: 'sandbox-runtime',\n scopes: ['sandbox:read'], // this key can do only this\n})\n```\n\nLeave `scopes` unset for a key that acts with its owner's full rights. This is\nwhat makes one stable service user safely able to own keys of very different\npower — the restriction lives on the key, not on a proliferation of identities.\n\n### Failure handling is deliberately split\n\nA key that fails to verify is logged and treated as an ordinary \"not\nauthenticated\" — an unusable credential is not an outage. A failure *inside*\n`mapKey` (your scope store is down) propagates as a real error instead. That\nasymmetry is on purpose: a scope lookup that silently failed would serve the\nrequest anonymously, which is exactly the wrong direction to fail in.\n\n### `betterAuthStatelessSession` has no machine path\n\nThe lean cookie-cache middleware (`betterAuthStatelessSession` — no\n`services.auth()`, no DB) handles only the human path. Machine auth needs\n`betterAuthSession`, because `verifyApiKey` is a server call there is no\nstateless equivalent of. Both accept an `impersonation` option.\n\n### WebSocket channels authenticate on the upgrade handshake\n\nGenerated channel CLI clients attach the credential as a connection header\n(`x-api-key` for `PIKKU_API_KEY`, else `Authorization: Bearer` from\n`~/.pikku/session.json`). The `@pikku/ws` server copies the upgrade-request\nheaders into the channel's `http.request` and runs the inherited HTTP `*`\nmiddleware during `runUpgradeMiddleware`, so `betterAuthSession` resolves the\nsession before the channel opens. For this to work the app must register\n`betterAuthSession` via `addHTTPMiddleware([...])` (the `*` group) — not only on\nspecific routes — so it is inherited into the channel upgrade. Browser clients\ncannot set WebSocket headers, so header-auth only covers the Node CLI path; a\nbrowser channel needs a query-param/subprotocol vector instead.\n\n## Gotchas\n\n- `apiKey()` rejects `metadata` unless `enableMetadata: true`.\n- `deviceAuthorization()` requires a `schema` option (pass `schema: {}`).\n- Keep the two paths on **different headers** — `x-api-key` (machine) vs\n `Authorization: Bearer` (human). One header for both reintroduces ambiguity.\n- The `apikey` table is plugin-contributed — add the SQL migration + regen types.\n- `~/.pikku/session.json` is written `0600` and stores the token + expiry; the\n CLI uses the expiry to detect when a re-login is needed.\n", "pikku-mcp/SKILL.md": "---\nname: pikku-mcp\ndescription: >-\n Use when exposing Pikku functions as MCP tools, resources, or prompts for AI assistants. Covers\n mcp: true, pikkuMCPToolFunc, pikkuMCPResourceFunc, pikkuMCPPromptFunc, wireMCPResource,\n wireMCPPrompt, the MCP wire object and PikkuMCPServer. TRIGGER when: code uses mcp: true or any\n pikkuMCP*Func/wireMCP* helper, user asks about MCP, Model Context Protocol, AI tool integration,\n or exposing functions to Claude/ChatGPT. DO NOT TRIGGER when: user asks about AI agents (use\n pikku-ai-agent) or general function definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku MCP Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nExpose Pikku functions as Model Context Protocol (MCP) tools, resources, and prompts for AI assistants like Claude, ChatGPT, and others.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions that could become MCP tools\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## The shape of MCP in Pikku\n\nMCP has three surfaces, and Pikku wires them differently:\n\n| Surface | Function factory | Wiring | Return type |\n| --- | --- | --- | --- |\n| **Tool** | `mcp: true` on a `pikkuFunc`, or `pikkuMCPToolFunc` | none — the function *is* the registration | the func's own output, or MCP content blocks |\n| **Resource** | `pikkuMCPResourceFunc` | `wireMCPResource({ uri, title, … })` | `Array<{ uri, text }>` |\n| **Prompt** | `pikkuMCPPromptFunc` | `wireMCPPrompt({ name, description, … })` | `Array<MCPPromptMessage>` |\n\nTools are the odd one out — there is no `wireMCPTool`. Resources and prompts\ncarry protocol metadata (a URI template, a prompt name) that belongs to the\nendpoint rather than the implementation, so that metadata lives on the wiring and\nthe `pikkuMCP*Func` factory stays a plain function.\n\nImport every factory and wiring from `#pikku`.\n\n## API Reference\n\n### Tools\n\nAdd `mcp: true` to any existing function:\n\n```typescript\nexport const createTodo = pikkuFunc({\n description: 'Create a new todo item', // becomes the MCP tool description\n input: CreateTodoInput, // becomes the MCP tool input schema\n output: CreateTodoOutput,\n mcp: true,\n func: async ({ db }, { text, priority }) => db.createTodo({ text, priority }),\n})\n```\n\nA missing `description` is all an assistant has to go on, so codegen warns about\nit rather than failing — treat the warning as a bug.\n\nUse `pikkuMCPToolFunc` when the tool should control its own presentation. It\nreturns MCP content blocks (`{ type: 'text', text }` or `{ type: 'image', data }`\nwith base64), so the assistant reads prose rather than raw JSON:\n\n```typescript\nimport { pikkuMCPToolFunc } from '#pikku'\n\nexport const createTodoTool = pikkuMCPToolFunc({\n description: 'Create a todo item with title, priority, due date and tags',\n input: CreateTodoWithUserInputSchema,\n func: async (_services, input, { rpc }) => {\n const { todo } = await rpc.invoke('createTodo', input)\n return [\n { type: 'text' as const, text: `Created \"${todo.title}\" (${todo.id})` },\n ]\n },\n})\n```\n\nIt also accepts `name`, `title`, `summary`, `tags`, `middleware` and\n`permissions`. The function is sessionless and gets `mcp` and `rpc` on its wire —\ncalling existing business functions through `rpc.invoke` keeps the tool a thin\npresentation layer over logic that is already tested and reachable over HTTP.\n\n### Resources\n\n```typescript\nimport { pikkuMCPResourceFunc } from '#pikku'\n\nexport const getTodoResource = pikkuMCPResourceFunc<{ id: string }>(\n async (_services, { id }, { rpc, mcp }) => {\n const { todo } = await rpc.invoke('getTodo', { id })\n return [\n {\n uri: mcp.uri!,\n text: todo ? formatTodo(todo) : `Todo \"${id}\" not found.`,\n },\n ]\n }\n)\n```\n\nThe factory takes either a bare function (as above) or a config object — `{ func, name }`,\nor `{ func, input }` with a schema. A resource returns `Array<{ uri, text }>`;\nit is text only, with no blob variant. `mcp.uri` is the concrete URI the client\nasked for, which is why each entry echoes it back.\n\n```typescript\nimport { wireMCPResource } from '#pikku'\n\nwireMCPResource({\n uri: 'todos/{id}', // URI template\n title: 'Todo Details',\n description: 'Get details of a specific todo by ID',\n func: getTodoResource,\n tags: ['todos'],\n // also: summary?, mimeType?, size?, streaming?, errors?, middleware?\n})\n```\n\nEvery `{param}` in `uri` is checked against the function's input at compile time,\nso `todos/{id}` wired to a function whose input has no `id` fails to build rather\nthan handing the function an `undefined`.\n\n### Prompts\n\n```typescript\nimport { pikkuMCPPromptFunc, wireMCPPrompt } from '#pikku'\n\nexport const planDayPrompt = pikkuMCPPromptFunc({\n input: UserIdInputSchema,\n func: async (_services, { userId }, { rpc }) => {\n const { todos } = await rpc.invoke('listTodos', { userId, completed: false })\n return [\n {\n role: 'user' as const,\n content: {\n type: 'text' as const,\n text: `Plan my day:\\n${todos.map(formatTodo).join('\\n')}`,\n },\n },\n ]\n },\n})\n\nwireMCPPrompt({\n name: 'planDay',\n description: 'Generate a daily plan based on pending todos',\n func: planDayPrompt,\n tags: ['productivity'],\n})\n```\n\nA message's `role` is `'user' | 'assistant' | 'system'` and its `content.type` is\n`'text' | 'image'`. The prompt arguments the client sees are derived from the\ninput schema at codegen time: each property becomes a named argument, and\nschema-required properties become required arguments.\n\n### MCP Wire Object\n\nAvailable as `wire.mcp` inside any MCP function:\n\n```typescript\nmcp.uri // the resolved resource URI (resources only)\nmcp.sendResourceUpdated(uri) // notify clients a resource changed\nawait mcp.enableTools({ archiveTodos: true })\nawait mcp.enableResources({ todoDetails: false })\nawait mcp.enablePrompts({ planDay: true })\n```\n\nThe `enable*` calls are how a server presents a changing surface — hiding tools\nthat are meaningless in the current state beats letting the assistant call them\nand fail. Each returns a boolean, and each name is typechecked against your\ngenerated endpoint names.\n\n```typescript\nexport const deleteTodo = pikkuFunc({\n description: 'Delete a todo item',\n mcp: true,\n func: async ({ db }, { id }, { mcp }) => {\n await db.deleteTodo(id)\n mcp.sendResourceUpdated(`todos/${id}`)\n return { deleted: true }\n },\n})\n```\n\n## MCP Server Setup\n\n`PikkuMCPServer` takes the server config and a logger — not your services. It\nloads the generated `mcp.gen.json`, and the bootstrap import is what registers\nyour functions.\n\n```typescript\n// start.ts\nimport { PikkuMCPServer } from '@pikku/modelcontextprotocol'\nimport { createConfig, createSingletonServices } from './services.js'\nimport mcpJSON from '../.pikku/mcp/mcp.gen.json' with { type: 'json' }\nimport '../.pikku/pikku-bootstrap.gen.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst server = new PikkuMCPServer(\n {\n name: 'pikku-mcp-server',\n version: '1.0.0',\n mcpJSON,\n capabilities: { logging: {}, tools: {}, resources: {}, prompts: {} },\n },\n singletonServices.logger\n)\n\nawait server.init()\n\n// stdio — the transport desktop MCP clients spawn\nawait server.connectStdio()\nsingletonServices.logger = server.createMCPLogger()\n\n// …or streamable HTTP, for a hosted server\nconst { close } = await server.connectHTTP({ port: 3000, host: '127.0.0.1' })\n```\n\n`capabilities` is a filter, not documentation: a surface you leave out is not\nadvertised and its endpoints are never loaded, which is how you ship a tools-only\nserver.\n\nOver stdio the protocol owns stdout, so an ordinary console logger corrupts the\nframes — that is what `createMCPLogger()` is for. Swap the logger before\nanything logs.\n\n## Red flags\n\n| Symptom | Cause |\n| --- | --- |\n| `wireMCPTool` is not exported | There is no tool wiring — use `mcp: true` or `pikkuMCPToolFunc` |\n| `uri`/`title` rejected on `pikkuMCPResourceFunc` | Those belong on `wireMCPResource` |\n| Resource returning `{ uri, blob, mimeType }` | Resources are text only: `{ uri, text }` |\n| Client sees a tool with no description | `mcp: true` without a `description` — check the codegen warning |\n| stdio client disconnects on the first log line | Logger still writing to stdout; use `createMCPLogger()` |\n", "pikku-middleware/references/middleware-patterns.md": "# Middleware Patterns (extended)\n\nDetailed, less-common middleware recipes. The common-path bearer-auth pattern lives inline in SKILL.md; this file holds the client-side caller, session-setting, and audit recipes.\n\n## Service-to-Service: the client (caller) side\n\nUse the generated `RPCInvoke` type from `.pikku/rpc/pikku-rpc-wirings-map.gen.d.ts` — never hand-write the input/output types:\n\n```typescript\nimport type { RPCInvoke } from '../../backends/my-service/.pikku/rpc/pikku-rpc-wirings-map.gen.d.js'\n\nexport function getServiceRPC(baseUrl: string, token: string): RPCInvoke {\n return async (name: string, data?: unknown) => {\n const res = await fetch(`${baseUrl}/rpc/${String(name)}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ data: data ?? {} }),\n })\n if (!res.ok) {\n const text = await res.text().catch(() => '')\n throw new Error(`rpc ${String(name)} failed: ${res.status} ${text}`)\n }\n return res.json()\n } as RPCInvoke\n}\n```\n\n## Session-Setting Middleware\n\n```typescript\nconst apiKeyAuth = pikkuMiddleware(async ({ kysely }, { http, setSession, session }, next) => {\n if (session) return next() // already authenticated\n\n const header = http?.request?.header?.('x-api-key')\n if (!header) return next()\n\n const row = await kysely.selectFrom('apiKey').select('userId').where('key', '=', header).executeTakeFirst()\n if (row) setSession?.({ userId: row.userId })\n\n return next()\n})\n\naddTagMiddleware('api-key-auth', [apiKeyAuth])\n```\n\nFunctions tagged `'api-key-auth'` with `auth: true` reject requests without a valid key; those with `auth: false` can inspect the session but won't reject.\n\n## Request Logging / Audit\n\n```typescript\nconst auditLog = pikkuMiddleware(async ({ logger, db }, wire, next) => {\n const start = Date.now()\n await next()\n await db.createAuditLog({ duration: Date.now() - start })\n})\n\naddHTTPMiddleware('/admin/*', [auditLog])\n```\n", "pikku-middleware/SKILL.md": "---\nname: pikku-middleware\ndescription: >-\n Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware\n (including service-to-service bearer auth), per-route middleware, session-setting middleware, or\n understanding middleware execution order and priority. TRIGGER when: user wants middleware on\n some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global\n interceptors, or middleware priority/order questions. DO NOT TRIGGER when: user asks about\n permissions/authorization checks (use pikku-permissions), auth strategies like\n authBearer/authCookie (use pikku-security), or deployment.\ninstallGroups: [core]\n---\n\n# Pikku Middleware\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info middleware --verbose` and `pikku info tags --json` to understand the existing middleware and tag landscape.\n2. Identify the source files that own the behavior — wirings files, not generated output.\n3. Register middleware at module load time — in a `wirings/*.ts` file, never inside a function body.\n4. Validate: run `pikku all --tsc` after adding or changing middleware — it regenerates and then confirms type safety in one pass.\n\n## The `pikkuMiddleware` Factory\n\n```typescript\nimport { pikkuMiddleware } from '#pikku'\n\n// Simple: just a function\nconst myMiddleware = pikkuMiddleware(async (services, wire, next) => {\n // runs before the function\n await next()\n // runs after the function (optional)\n})\n\n// With metadata (name + priority)\nconst telemetryMiddleware = pikkuMiddleware({\n name: 'my-telemetry',\n priority: 'highest',\n func: async (services, wire, next) => {\n const start = performance.now()\n try {\n await next()\n } finally {\n services.logger.info({ duration: Math.round(performance.now() - start) })\n }\n },\n})\n```\n\nThe `wire` object gives you:\n- `wire.http` — inbound HTTP context (headers, URL, cookies)\n- `wire.setSession(session)` — set the session for this request\n- `wire.getSession()` — read the current session\n- `wire.session` — the session set so far (may be undefined)\n\nThrow a typed error to abort: `UnauthorizedError`, `ForbiddenError`, etc. from `@pikku/core/errors`.\n\n## Scoping: Five Levels\n\nFrom broadest to narrowest:\n\n```typescript\n// 1. Wire-agnostic global: all wire types (HTTP, Queue, Channel, Trigger, Workflow, ...)\naddGlobalMiddleware([telemetryOuter()])\n\n// 2. HTTP global: all HTTP routes\naddHTTPMiddleware('*', [cors(), authBearer()])\n\n// 3. Prefix-based: URL pattern\naddHTTPMiddleware('/admin/*', [auditLog])\n\n// 4. Tag-based: any wiring with matching tag\naddTagMiddleware('machine-agent', [bearerAuth]) // tag on function or wire\n\n// 5. Inline: per-wiring\nwireHTTP({\n route: '/books/:id',\n func: getBook,\n middleware: [cacheControl],\n})\n```\n\n## Global Middleware (`addGlobalMiddleware`)\n\nRuns before everything else, across every wire type: HTTP, Queue, Channel, Trigger, Scheduler, Workflow, Agent, CLI, MCP. Use it for cross-cutting concerns (e.g. telemetry) that must wrap every invocation regardless of transport.\n\n```typescript\nimport { addGlobalMiddleware } from '@pikku/core'\nimport { telemetryOuter, telemetryInner } from '@pikku/core/middleware'\n\naddGlobalMiddleware([telemetryOuter({ environmentId: env.STAGE_ID })]) // wraps the full call\naddGlobalMiddleware([telemetryInner({ environmentId: env.STAGE_ID })]) // closest to the function body\n```\n\n`telemetryOuter` ships with `priority: 'highest'`, `telemetryInner` with `priority: 'lowest'` — so priority sorting places outer first regardless of array/call order.\n\n## HTTP & Prefix Middleware (`addHTTPMiddleware`)\n\n```typescript\nimport { addHTTPMiddleware } from '@pikku/core/http'\nimport { cors, authBearer } from '@pikku/core/middleware'\n\n// All routes\naddHTTPMiddleware('*', [cors({ origin: 'https://app.example.com', credentials: true })])\n\n// Scoped to /api/* prefix\naddHTTPMiddleware('/api/*', [rateLimit({ maxRequests: 100, windowMs: 60_000 })])\n```\n\n## Tag Middleware (`addTagMiddleware`)\n\nTag middleware fires for any wiring (function or wire object) that carries a matching tag. This is the canonical approach for service-to-service bearer auth, rate limiting a group, or any cross-cutting concern scoped to a subset of routes.\n\n### Setting Tags\n\n```typescript\n// On the function definition\nexport const myFunc = pikkuSessionlessFunc({\n auth: false,\n tags: ['machine-agent'],\n func: async (services, input) => { ... },\n})\n\n// On the wire object\nwireHTTP({\n route: '/internal/action',\n method: 'post',\n auth: false,\n tags: ['internal'],\n func: myFunc,\n})\n```\n\nTags from the function definition and the wire object are merged — middleware from both tag sets runs.\n\n### Registering Tag Middleware\n\n```typescript\nimport { addTagMiddleware } from '#pikku'\n\naddTagMiddleware('machine-agent', [machineAgentBearerAuth])\n```\n\nCall at module load time — typically in the same `wirings/*.ts` file as the `wireHTTP` calls that use the tag.\n\n## Middleware Execution Order\n\nResolution happens in two steps, and the order matters more than it looks.\n\n**Step 1 — collect, broadest → narrowest:**\n\n```text\nglobal → httpGroup/* → httpGroup/prefix → wiringTags → wiringMiddleware → funcTags → funcMiddleware → function body\n```\n\n**Step 2 — sort that whole flat list by priority:**\n\n```text\nhighest → high → medium (default) → low → lowest\n```\n\n**Priority is the primary key across every scope, not within one.** The collected\nlist is flattened first and sorted once, so a `priority: 'lowest'` global\nmiddleware runs *after* an inline per-route middleware of default priority — the\nnarrower scope does not win. Scope order survives only as the tiebreaker between\nmiddleware of equal priority, because the sort is stable.\n\nThis is what makes `telemetryOuter`/`telemetryInner` work: they pin themselves to\n`highest`/`lowest` so they bracket every other middleware no matter where those\nwere registered.\n\nSet priority using the config-object form of `pikkuMiddleware`:\n\n```typescript\nconst earlyMiddleware = pikkuMiddleware({\n name: 'early',\n priority: 'highest', // 'highest' | 'high' | 'medium' | 'low' | 'lowest'\n func: async (services, wire, next) => { ... },\n})\n```\n\nWithin the same priority level, the collection order above is preserved. Use priority when a middleware must run before/after others regardless of where it was registered (e.g. telemetry wrapping everything, session extraction before auth checks).\n\n## Service-to-Service Bearer Auth (canonical pattern)\n\nA server that exposes RPCs only to a trusted caller (e.g. an API calling a machine-agent). Auth lives in a tag middleware — NOT in the function body. Authorization/permission checks belong in the `permissions` field (see `pikku-permissions`), never inside `func`.\n\n**On the server (the service being called):** tag the function, register a `pikkuMiddleware` that reads the `Authorization` header on that tag.\n\n```typescript\n// lib/host-token.ts\nlet _token: string | null = null\nexport const setToken = (t: string) => { _token = t }\nexport const getToken = () => _token\n```\n\n```typescript\n// wirings/http.wiring.ts\nimport { timingSafeEqual } from 'node:crypto'\nimport { addTagMiddleware, pikkuMiddleware } from '#pikku'\nimport { UnauthorizedError } from '@pikku/core/errors'\nimport { getToken } from '../lib/host-token.js'\n\nconst bearerAuth = pikkuMiddleware(async (_services, { http }, next) => {\n const authHeader = http?.request?.header?.('authorization') || http?.request?.header?.('Authorization')\n const token = getToken()\n const expected = token ? `Bearer ${token}` : null\n if (\n !expected ||\n !authHeader ||\n authHeader.length !== expected.length ||\n !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))\n ) {\n throw new UnauthorizedError()\n }\n return next()\n})\n\naddTagMiddleware('machine-agent', [bearerAuth])\n```\n\n```typescript\n// functions/my.function.ts\nexport const myFunc = pikkuSessionlessFunc({\n expose: true,\n auth: false,\n tags: ['machine-agent'],\n func: async (services, input) => { ... },\n})\n```\n\n**On the client (the caller):** use the generated `RPCInvoke` type — never hand-write a `fetch` wrapper's types. See `references/middleware-patterns.md`.\n\n## More patterns\n\n`references/middleware-patterns.md` covers the client-side `RPCInvoke` caller, session-setting middleware (set a session from an API key), and request logging / audit middleware.\n\n## After Changes\n\n```bash\npikku all # regenerate metadata so new tags are picked up\npikku all --tsc # regenerate, then type-check (fails on type errors)\n```\n", "pikku-mongodb/SKILL.md": "---\nname: pikku-mongodb\ndescription: >-\n Use when setting up MongoDB database services in a Pikku app. Covers PikkuMongoDB connection,\n channel stores, workflow services, secret services, AI storage, agent runs, and deployment\n services. TRIGGER when: code uses PikkuMongoDB, MongoDBChannelStore, MongoDBWorkflowService,\n MongoDBSecretService, or user asks about MongoDB setup with Pikku. DO NOT TRIGGER when: user\n asks about SQL databases (use pikku-kysely) or Redis (use pikku-redis).\n---\n\n# Pikku MongoDB\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/mongodb` provides MongoDB-backed implementations of Pikku's core service interfaces.\n\n## Installation\n\n```bash\nyarn add @pikku/mongodb\n```\n\n## API Reference\n\n### `PikkuMongoDB` (Connection Wrapper)\n\n```typescript\nimport { PikkuMongoDB } from '@pikku/mongodb'\n\nconst mongo = new PikkuMongoDB(\n logger: Logger,\n clientOrUri: MongoClient | string,\n dbName: string,\n options?: MongoClientOptions\n)\n\nawait mongo.init()\nmongo.db // Db instance for queries\nawait mongo.close()\n```\n\n### Available Services\n\n| Service | Interface | Purpose |\n| --------------------------- | ------------------------------------- | ---------------------------------------------- |\n| `MongoDBChannelStore` | `ChannelStore` | WebSocket channel state persistence |\n| `MongoDBEventHubStore` | `EventHubStore` | Event hub state persistence |\n| `MongoDBWorkflowService` | `PikkuWorkflowService` | Workflow definition storage |\n| `MongoDBWorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |\n| `MongoDBDeploymentService` | `DeploymentService` | Deployment state management |\n| `MongoDBAIStorageService` | `AIStorageService, AIRunStateService` | AI conversation/run storage |\n| `MongoDBAgentRunService` | `AgentRunService` | Agent execution tracking |\n| `MongoDBSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |\n| `MongoDBSessionStore` | `SessionStore` | Persisted user sessions |\n\nAll services take a `Db` instance in their constructor and have an `init()` method that creates collections/indexes.\n\n### Secret Service\n\nEnvelope encryption: `key` derives the KEK that wraps each secret's own DEK.\nKeeping `previousKey` set is what makes `rotateKEK()` possible — it re-wraps\nevery secret onto the current key and returns the new version.\n\n```typescript\nimport { MongoDBSecretService } from '@pikku/mongodb'\n\nconst secrets = new MongoDBSecretService(mongo.db, {\n key: 'your-key-encryption-passphrase',\n keyVersion: 2, // defaults to 1\n previousKey: 'the-passphrase-you-are-rotating-away-from',\n audit: true, // log write/delete/rotate through the audit sink\n auditReads: false, // reads too — noisy, off by default\n})\nawait secrets.init()\n\nawait secrets.setSecret('api-key', { key: 'sk-...' })\nconst value = await secrets.getSecret<{ key: string }>('api-key')\nawait secrets.rotateKEK()\n```\n\n## Usage Patterns\n\n### Full Setup\n\n```typescript\nimport {\n PikkuMongoDB,\n MongoDBChannelStore,\n MongoDBWorkflowService,\n} from '@pikku/mongodb'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const mongo = new PikkuMongoDB(logger, config.mongoUri, 'myapp')\n await mongo.init()\n\n const channelStore = new MongoDBChannelStore(mongo.db)\n await channelStore.init()\n\n const workflowService = new MongoDBWorkflowService(mongo.db)\n await workflowService.init()\n\n return { config, logger, database: mongo, channelStore, workflowService }\n})\n```\n", "pikku-n8n-import/references/addon-mapping.md": "# Integration stub → Pikku addon\n\nTranslate n8n integration nodes (`gmailTool`, `slackTool`, `googleSheetsTool`, plain\n`gmail` / `slack` action nodes, etc.) that the importer left as throwing stubs into\nreal `ref('<addonRpc>')` references pointing at functions in **installed**\n`@pikku/addon-*` packages.\n\nThis is **per-stub mechanical**. Do not invent business logic, chain calls, or\n\"improve\" the workflow. Read one manifest entry, find the matching addon function,\nrewrite the stub.\n\n## Inputs\n\n1. **Manifest** — `<workflow>.integrations.json` next to the `.graph.ts`. Each entry:\n ```jsonc\n {\n \"rpcName\": \"agentGmailtool__sendAMessageInGmail\",\n \"n8nType\": \"n8n-nodes-base.gmailTool\",\n \"n8nName\": \"Send a message in Gmail\",\n \"parameters\": { \"sendTo\": \"...\", \"message\": \"...\", \"subject\": \"...\" },\n \"credentials\": { \"gmailOAuth2\": { \"id\": \"...\", \"name\": \"Personal Gmail\" } },\n \"isAgentTool\": true,\n \"agentName\": \"Inbox Assistant\"\n }\n ```\n2. **Installed addons** — `@pikku/addon-*` in the project's `package.json`\n `dependencies`. Map **only** against installed packages. If the addon for an n8n\n type is not installed, surface it (see SKILL step 4) — never silently skip and\n never pick a vaguely-named function from another addon.\n\n## Per entry, in order\n\n### Step 1 — identify the target addon\n\nMap `n8nType` to a package by reading its source. Common shapes (**guesses, not\nauthoritative** — always verify against installed source):\n\n| n8n type prefix | typical addon candidate |\n|---|---|\n| `n8n-nodes-base.gmail` / `gmailTool` | `@pikku/addon-email-gmail` |\n| `n8n-nodes-base.slack` / `slackTool` | `@pikku/addon-chat-slack` |\n| `n8n-nodes-base.googleSheets` / `…Tool` | `@pikku/addon-sheets-google` |\n| `n8n-nodes-base.notion` / `notionTool` | `@pikku/addon-docs-notion` |\n| `n8n-nodes-base.telegram` / `telegramTool` | `@pikku/addon-chat-telegram` |\n\nIf no installed addon plausibly covers the n8n type, stop and report it — do not\npick a wrong addon.\n\n### Step 2 — pick the function (resource + operation → fn name)\n\nn8n nodes use a `(resource, operation)` pair. Rubric:\n\n- `resource` defaults to the integration's primary noun if absent (gmail →\n `message`, slack → `message`, sheets → `spreadsheet`). Read the addon's folder\n structure (`messages/`, `drafts/`, `channels/`) to see what nouns exist.\n- `operation` is usually a verb (`get`, `getAll`, `send`, `delete`, `addLabels`).\n- The pikku function name is almost always `<resource><Verb>` in camelCase, matching\n the file's `export const` (`messageList` for `messages/list.function.ts`).\n\nVerify: `grep -h \"^export const\" <addonPkg>/src/functions/**/*.ts` and match by name.\nConventions in `@pikku/addon-email-gmail` (a **sanity reference**, not a fallback):\n\n- `getAll → <resource>List`, `get → <resource>Get`, `send → <resource>Send`,\n `delete → <resource>Delete`, `reply → <resource>Reply`\n- `addLabels → <resource>AddLabel` (singular!), `removeLabels → <resource>RemoveLabel`\n- `markAsRead / markAsUnread → <resource>MarkRead / <resource>MarkUnread`\n- `create → <resource>Create`\n\nIf the function has a `node:` block, prefer matching its `category`/`displayName`\nover guessing.\n\n### Step 3 — rewrite the stub\n\nTwo outcomes, by `isAgentTool`:\n\n**A) `isAgentTool: true`** — the stub is an agent tool referenced via `ref()`:\n1. Delete the stub file.\n2. In the agent file, replace `ref('agentGmailtool__sendAMessageInGmail')` in\n `tools: [...]` with `ref('messageSend')` (the resolved addon function).\n3. Ensure the addon is where pikku scans functions (usually automatic via\n `node_modules/@pikku/addon-*`).\n\nIf you can't delete safely, leave a one-line re-export instead of a stub:\n```ts\nimport { messageSend } from '@pikku/addon-email-gmail'\nexport const agentGmailtool__sendAMessageInGmail = messageSend\n```\nDefault is delete + retarget; wrappers add maintenance burden.\n\n**B) `isAgentTool: false`** — the stub is a graph node:\n1. Open `<workflow>.graph.ts`.\n2. In `nodes: { … }` find the entry whose value is the stub rpc name.\n3. Replace it with the addon function name (`'messageSend'`).\n4. If `config: { <id>: { input } }` produces an `{ items }` envelope, rewrite it to\n the addon function's real input schema (read its `input: z.object({...})`).\n5. Delete the stub file.\n\n### Step 4 — port hard-coded parameters\n\n- **Hardcoded values** (`\"limit\": 20`, `\"labelIds\": [\"INBOX\"]`) were user choices.\n Preserve them in the graph node's `input` (case B). **Agent tools cannot carry\n hardcoded params** (the LLM fills args at call time) — surface the trade-off; if\n the user needs a value pinned, they keep a thin wrapper.\n- **`$fromAI('Name', '', 'string')`** placeholders are LLM-filled — the addon's Zod\n schema becomes the tool schema. Just drop the placeholder string; no other action.\n\n### Step 5 — credentials\n\n`credentials: { gmailOAuth2: { id, name } }` is the n8n credential ref. Pikku addons\nexpect a wired service (`services.gmail`). Do **not** auto-wire — leave a TODO:\n\n> `// TODO: wire services.gmail using credential \"Personal Gmail\" (n8n id: gmail_cred_1) — see @pikku/addon-email-gmail/README.md`\n\n## Never\n\n- Invent functions that don't exist — grep first.\n- Pick a wrong addon because the right one isn't installed — report `npm i @pikku/addon-<x>`.\n- Bake per-mapping tables into `@pikku/n8n-import` — it is addon-agnostic; mapping lives here.\n- Modify the manifest — it's an audit artifact.\n- Chain calls — each entry maps to exactly one addon function.\n- Silently drop a hardcoded param — surface it.\n", "pikku-n8n-import/references/code-translation.md": "# n8n Code node → Pikku function\n\nReplace a Code-node stub's `throw new Error(...)` body with a faithful TypeScript\nreimplementation. The original JS is preserved verbatim in the JSDoc above the\nfunction. Keep the signature and JSDoc intact; only widen the Zod input/output if\nthe code's data shape demands it.\n\n**Narrow, mechanical translation** — behavioral parity, not better code. Do not\nrefactor, add error handling, or invent fields.\n\n## Process\n\n1. **Read the file.** Identify the input schema, output schema, the verbatim JS in\n the JSDoc, and the `pikkuSessionlessFunc` shape.\n2. **Determine the n8n mode** from the original JSON if available (importer leaves it\n in `fixtures/`, or ask). Two modes:\n - `runOnceForAllItems` (default) — code runs once with `items: Array<{ json, binary, pairedItem }>`, returns an array of envelopes.\n - `runOnceForEachItem` — runs once per item, `$json` / `$input.item.json` in scope, returns a single envelope.\n - If unknown, infer: bare `items.X` → all-items; bare `$json.X` / `$input.item.X` → each-item.\n3. **Apply the rubric.**\n4. **Edit only the function body.** Leave imports, schemas, JSDoc, name, description, refs untouched unless step 5 forces it.\n5. **If the schemas are wrong** (code reads `$json.userId: string` but input is `items: z.array(z.unknown())`), tighten with the smallest change. Prefer `z.unknown()` over `z.any()`. Never widen output to `z.any()`.\n6. **Add one comment** at the top of the body noting the mode: `// translated from n8n Code node, mode: runOnceForAllItems`. This is the *only* comment you may add.\n7. **Typecheck** (`yarn tsc` from the package root); fix errors with the smallest change.\n\n## Rubric\n\n### Envelope unwrapping — all-items mode\n\n| n8n | Pikku |\n|---|---|\n| `items` | `(data.items ?? []) as any[]` (or typed if known) |\n| `items[i].json.X` | `items[i].X` |\n| `items[i].json` | `items[i]` |\n| `items[i].binary` | **NOT supported** — leave a TODO and explain |\n| `items.length` | `items.length` |\n| `items.map(i => i.json.X)` | `items.map((i: any) => i.X)` |\n\n### Envelope unwrapping — each-item mode\n\n| n8n | Pikku |\n|---|---|\n| `$json.X` / `$input.item.json.X` | `data.X` (input is the item itself) |\n| `$input.item.json` | `data` |\n| `$input.all()` | not available per-item — change to all-items mode |\n\n### Return statement\n\n| n8n | Pikku |\n|---|---|\n| `return [{ json: X }]` | `return { items: [X] }` |\n| `return items.map(i => ({ json: ... }))` | `return { items: items.map(...) }` |\n| `return [{ json: X }, { json: Y }]` | `return { items: [X, Y] }` |\n| `return { json: X }` (each-item) | `return X` |\n| `return [...]` (already plain) | wrap in `{ items: [...] }` only if the output schema expects it |\n\n### Built-ins — do NOT auto-translate\n\nIf the code references any of these, **stop**, leave the body a stub, and annotate\n`// TODO:` + explain:\n\n- `this.helpers.*` (binary buffers, HTTP requests, prepareBinaryData)\n- `$node['Some Node'].json` (cross-node refs — resolve via Pikku `ref()` upstream, not in the body)\n- `$workflow`, `$execution`, `$item()`, `$items('Other Node')`\n- `getBinaryDataBuffer` / `getStaticData` — no equivalent, TODO\n- `require()` / dynamic `import()` — flag and stop\n\nTranslate these only when reachable: `$now`/`$today` → `new Date()`; `$env.X` →\n`services.variables.get('X')` (never `process.env` — Pikku house rule; `services` is\nthe first param).\n\n### Async / types\n\n- Original uses `await` → the body is already `async`; keep every `await`.\n- `this.helpers.httpRequest(...)` → do NOT inline; the user should use a separate\n `httpRequest` rpc node. Leave a `// TODO:` and explain.\n- Cast `items` as `any[]` only if the schema is `z.array(z.unknown())`; use the\n inferred type if tightened. Never `as any` on the return — fix the schema instead.\n\n## Example\n\nBefore (stub):\n```ts\n/**\n * STUB — generated from n8n Code node \"Custom Code\".\n * const total = items.reduce((acc, i) => acc + i.json.amount, 0);\n * return [{ json: { total } }];\n */\nexport const codeStubCustomCode = pikkuSessionlessFunc({\n input: CodeStubCustomCodeInput,\n output: CodeStubCustomCodeOutput,\n func: async (_services, _data) => {\n throw new Error('Stub: ported from n8n Code node \"Custom Code\" — implement me')\n },\n})\n```\n\nAfter:\n```ts\nexport const codeStubCustomCode = pikkuSessionlessFunc({\n description: 'Ported from n8n Code node \"Custom Code\"',\n input: CodeStubCustomCodeInput,\n output: CodeStubCustomCodeOutput,\n func: async (_services, data) => {\n // translated from n8n Code node, mode: runOnceForAllItems\n const items = (data.items ?? []) as any[]\n const total = items.reduce((acc, i) => acc + i.amount, 0)\n return { items: [{ total }] }\n },\n})\n```\n\n## Report\n\nTerse: the mode you inferred (one sentence), the literal rubric translations\napplied, anything flagged TODO and why, any schema tightening (before → after).\n\nDo not add tests, refactor, edit other files, \"improve\" the logic, or add\ntry/catch unless the original did. If the code is empty, comment-only, or so\ndependent on n8n internals that no honest translation is possible, leave the stub\nand tell the user which n8n features block it.\n", "pikku-n8n-import/references/loops-and-control.md": "# Loops & control stubs\n\nThe importer maps the mechanical control flow (IF/Filter/Switch it can normalize →\n`graph:branch`) but leaves the **semantic** cases as `control` stubs — chiefly\n**Loop Over Items / splitInBatches** and Switch in expression mode. These need\njudgment, which is why they are not compiled. Read the loop body and the workflow\naround it; decide, or ask.\n\n## Loop Over Items / splitInBatches\n\nn8n's loop node has two outputs: **loop** (output 1, fires per batch) and **done**\n(output 0, fires once when iteration finishes). The loop body flows from the loop\noutput back into the node — a cycle. Pikku graphs are a DAG, so **the loop becomes a\n`graph:map`** (`@pikku/addon-graph`) and the back-edge disappears:\n\n```ts\ntheLoop: \"graph:map\", // (graph:fanout) — one child invocation per item\n// config:\ntheLoop: {\n input: (ref) => ({\n items: ref(\"<predecessor>\"), // what fed the loop\n child: \"<childRpc-or-subGraph>\", // the loop body\n childInput: { /* $item-rebound body input */ },\n stepPrefix: \"theLoop\",\n }),\n next: \"<done-branch target>\", // output 0\n}\n```\n\nInside `childInput`, references rebind to the current element: the body's `$json` /\npredecessor and any `$('<loop node>')` become `$item`.\n\n### Decide the shape first\n\n| Loop body does… | Emit |\n|---|---|\n| transform each item independently (enrich, format, call one thing) | `graph:map` — child = the body |\n| accumulate across items (running total, build one object/array) | a **reduce**: a single generated function over the whole array, not a map — `graph:map` collects per-item results and *loses* the accumulator |\n| pure side-effect per item, nothing downstream consumes results | `graph:map` with **no `next`** (done branch empty) — the safest, unambiguous case |\n\n### Child arity\n\n- **Single-node body** → `child: \"<that node's rpc>\"`, its input as `childInput`.\n- **Multi-node body** → the child must be a per-item **sub-graph**. Lift the body\n into its own `pikkuWorkflowGraph` (see `pikku-workflow`) and set `child` to that\n workflow's registered name. If the body references nodes **outside** the loop\n (not just the item), that value has to be threaded in as `childInput` — if you\n can't do it cleanly, stop and ask rather than emit something subtly wrong.\n\n### Done-branch semantics (ask if it matters)\n\nn8n's done output is version-dependent: it may carry the *original* items or the\n*accumulated* results. `graph:map`'s `next` receives the array of child results.\nIf a downstream node reads that array's shape and the distinction matters, add:\n\n```ts\n// TODO(n8n): done branch receives collected loop results (not original items) — confirm this matches intent\n```\n\nand call it out in your summary. When the done branch is empty, there's nothing to\ndecide.\n\n### batchSize > 1\n\n`graph:map` is one-item-at-a-time. A real numeric `batchSize` (chunk into groups,\nrun the body per chunk) has no direct primitive — leave the stub, and tell the user\nthis loop batches N-at-a-time and needs a manual pass (or a `graph:chunk` +\n`graph:map` composition if the body is chunk-shaped).\n\n## Switch / control stubs the importer couldn't normalize\n\nA Switch in **expression mode** (routing by an arbitrary JS expression rather than\ncomparable conditions) stays a `control` stub. Options, in order of preference:\n\n1. If the expression is really a set of value comparisons, rewrite the node as a\n `graph:branch` by hand (see `pikku-workflow` for the `branch` shape) and wire the\n emitted `next` keys to the branch targets.\n2. If it's genuinely computed routing, translate the stub into a small function that\n returns the branch key, then feed it a `graph:branch`.\n3. If neither is faithful, leave the stub and explain what the Switch does.\n\n## Never\n\n- Emit a `graph:map` for an accumulator loop — you'll silently drop the running state.\n- Guess the done-branch semantics when a downstream node depends on the shape — mark\n it and surface it.\n- Invent a batching primitive — say what's unsupported instead.\n", "pikku-n8n-import/SKILL.md": "---\nname: pikku-n8n-import\ndescription: 'Use to import an n8n workflow JSON export into a runnable Pikku workflow. Triggers when the user says \"import this n8n workflow\", \"convert this n8n export to pikku\", points at an n8n `.json` export or a directory of them, or picks up after `pikku import n8n` left throwing stub functions (`STUB — generated from n8n …`, `— implement me`) or a `<workflow>.integrations.json` manifest. Owns the whole flow: run the importer, triage what it could not map, fill each stub, report any missing `@pikku/addon-*` integrations, and verify the result compiles and runs with no surviving stubs. DO NOT TRIGGER for hand-written addon wiring unrelated to an n8n import (use pikku-addon), or for authoring workflows from scratch (use pikku-workflow).'\nmetadata:\n version: 1.0.0\n---\n\n# n8n → Pikku Import\n\nTake an n8n export all the way to a compiling, stub-free Pikku workflow. The\n`@pikku/n8n-import` package (invoked by `pikku import n8n`) is **frozen**: it does\nthe provable, mechanical conversion and leaves everything it cannot prove as a\ntyped stub that throws at runtime. This skill runs that package, then fills the\nremainder with judgment, reports what needs a human decision, and verifies.\n\nNever re-do what the importer already did, and never hand-edit generated files to\npaper over a stub — fix the source cause (the stub function, the graph node, or a\nmissing dependency).\n\n## Agent Operating Procedure\n\n1. Discover before editing. Prefer `pikku-meta`/`pikku meta ... --json` when\n available; inspect only the focused output you need.\n2. Identify the source file that owns the behavior. Do not start from generated\n output, `.pikku`, `node_modules`, or vendored packages.\n3. Make the smallest source change that satisfies the task. Keep generated files\n generated.\n4. Validate with the narrowest relevant command first, then `pikku all` /\n `pikku-verify` when functions, wirings, or schemas changed.\n5. If validation fails, fix the source cause and rerun. Never edit generated\n files to hide an error.\n\n## Workflow\n\n### 1 — Run the importer (do as much as possible, cheaply)\n\n```bash\npikku import n8n <file> [--out <dir>] # -o for short\n```\n\nThe output directory is an **option**, not a positional argument; omitted, it\nfalls back to `scaffold.functionDir` from `pikku.config.json`, then cwd.\n\n`<file>` is one export, **or a directory** — the command reads every `.json` in\nit — and either form may hold a single workflow object, a bare array (`n8n\nexport:workflow --all`), or a `{ workflows: [...] }` wrapper. All of those are\nflattened into one import per workflow, so there is no need to loop yourself.\n\nIt writes `<slug>.graph.ts` (+ `.agent.ts` for AI workflows), `<slug>.addons.gen.ts`,\na `<slug>.integrations.json` manifest, and one stub function per node it could not\nmap. An un-importable workflow (a cross-workflow sub-workflow reference, a dynamic\nworkflow target, a mid-flow `respondToWebhook`) is reported as `[reason] message`\nand **skipped** — nothing partial is written for it. Across a batch the others\nstill import; the command exits 1 at the end if any failed, so read the log rather\nthan the exit code to know what landed. Relay every skipped workflow to the user.\n\n### 2 — Triage what it left\n\nEvery unmapped node is a stub that throws `… — implement me`. Classify each by its\nJSDoc marker and route to the matching reference:\n\n| Stub marker / signal | Handle via |\n|---|---|\n| `STUB — generated from n8n node \"…\" (type \"n8n-nodes-base.<svc>…\")` | `references/addon-mapping.md` |\n| `STUB — generated from n8n Code node \"…\"` | `references/code-translation.md` |\n| A `control` stub — Loop Over Items / **splitInBatches**, Switch expr-mode | `references/loops-and-control.md` |\n| `STUB — … vector-store … #902` | rare now (RAG ships as `<store>:query`/`:ingest`); a residual one = an unmapped store → report it, don't guess |\n| Importer `diagnostics` (already exited 1) | explain the reason; the workflow is un-importable as-is |\n\nRead a reference file only when you actually hit that stub class.\n\n### 3 — Fill each stub\n\nWork the manifest + stub files per the routed reference. The mechanical classes\n(addon, code) are near-deterministic; the loop/control class needs judgment\n(map vs reduce, done-branch semantics) — reference `loops-and-control.md` tells you\nwhen to decide vs ask.\n\n### 4 — Report missing integrations (first-class output)\n\nAn addon stub can only be wired to an **installed** `@pikku/addon-*`. When the\npackage for an n8n service is not in `dependencies`, do not guess a lookalike —\ncollect it. Give the user one upfront list:\n\n```\nMissing integrations — install these or the nodes stay stubs:\n • slackTool \"Post to channel\" → npm i @pikku/addon-chat-slack\n • hubspot \"Create contact\" → no @pikku/addon-hubspot exists yet\n```\n\n### 5 — Verify it works\n\n1. `pikku all` (regenerate) → `yarn tsc` from the package root; fix the source\n cause of any error and rerun.\n2. Grep the emitted functions for any surviving `— implement me`\n / `throw new Error('Stub:`. **Any survivor means the import is not done** —\n list them by node name.\n3. Green tsc **and** zero surviving stubs = success.\n\n## References\n\n| Open when you need to… | Read |\n|---|---|\n| map an integration stub (gmailTool, slackTool, googleSheets, plain action nodes) to an installed addon `ref(...)` | `references/addon-mapping.md` |\n| translate an n8n Code node body into a Pikku function body | `references/code-translation.md` |\n| lower a Loop Over Items / splitInBatches loop, or a Switch that stayed a stub | `references/loops-and-control.md` |\n\n## Final summary\n\nReport, terse:\n\n- Files written and workflow shape (`pure-graph` / `agent`).\n- Stubs filled, by class.\n- **Missing integrations** (the step-4 list) — the thing the user must act on.\n- Anything left as a `// TODO:` and why (credentials to wire, a loop deferred, an\n unmapped store).\n- Verification: `tsc` status + surviving-stub count (must be 0).\n", "pikku-n8n-import/SPEC.md": "# n8n → Pikku Import Specification\n\n## Intent\n\nTake an n8n workflow JSON export all the way to a compiling, stub-free, runnable\nPikku workflow. The `@pikku/n8n-import` package is treated as **frozen**: it does\nthe provable mechanical conversion and leaves everything it cannot prove as a typed,\nthrowing stub. This skill owns the end-to-end flow around it — run it, fill the\nremainder with judgment, report gaps that need a human decision, and verify.\n\n## Scope\n\nIn scope:\n- Running `pikku import n8n` and triaging its output.\n- Filling integration stubs (→ addon refs), Code stubs (→ function bodies), and\n loop/control stubs (→ `graph:map`/reduce/branch).\n- Reporting missing `@pikku/addon-*` integrations as a first-class output.\n- Verifying via `pikku all` + `tsc` + a zero-surviving-stub check.\n\nOut of scope:\n- Extending `@pikku/n8n-import` itself (it is frozen; do not add per-service tables\n or new compiler rules to it).\n- Authoring workflows from scratch (`pikku-workflow`) or hand-written addon wiring\n unrelated to an import (`pikku-addon`).\n- Inventing batching primitives or guessing ambiguous loop semantics — surface them.\n\n## Users And Trigger Context\n\n- Primary users: developers importing their own n8n workflows into a Pikku app,\n usually inside an agentic session.\n- Common requests: \"import this n8n workflow\", \"convert this n8n export to pikku\",\n finishing `— implement me` stubs, wiring a `*.integrations.json` manifest.\n- Should not trigger for: from-scratch workflow authoring, or addon wiring with no\n n8n import involved.\n\n## Runtime Contract\n\n- Required first action: run `pikku import n8n <export.json> [outDir]` (per file for\n a directory); relay any exit-1 diagnostic instead of scaffolding a partial.\n- Required outputs: filled stubs, a missing-integrations list, verification status.\n- Non-negotiable: never hand-edit generated files to hide a stub; map only to\n installed addons; zero surviving `— implement me` stubs at success.\n- Bundled files loaded at runtime: `references/addon-mapping.md`,\n `references/code-translation.md`, `references/loops-and-control.md` — each only\n when its stub class appears.\n\n## Source And Evidence Model\n\nAuthoritative sources:\n- `@pikku/n8n-import` codegen (stub markers, manifest shape, `import-n8n` command).\n- `@pikku/addon-graph` function contracts (`graph:map`/`fanout`, `branch`).\n- Installed `@pikku/addon-*` source (function names verified by grep, never guessed).\n\nUseful improvement sources: real imported workflows, harness coverage deltas,\naddon catalogue changes.\n\nData that must not be stored: credential secrets, customer data, private ids beyond\nwhat a manifest already records for reproduction.\n\n## Reference Architecture\n\n- `SKILL.md`: the run → triage → fill → report → verify workflow + router.\n- `references/`: per-stub-class depth (addon mapping, code translation, loops/control).\n\n## Validation\n\n- Lightweight: `yarn tsc` from the package root after each fill.\n- Deeper: `pikku all` regeneration; grep emitted functions for surviving stub throws.\n- Acceptance gates: green tsc **and** zero surviving `— implement me` stubs.\n\n## Known Limitations\n\n- `splitInBatches` with `batchSize > 1` and reduce-style accumulators have no direct\n primitive — surfaced, not auto-converted.\n- Missing addons block their nodes; the skill reports, it does not install.\n- Cross-workflow sub-workflow references fail import at the package level.\n\n## Maintenance Notes\n\n- Update `SKILL.md` when the import command, stub taxonomy, or verify gates change.\n- Update a reference when an addon convention, the `graph:map` contract, or a rubric\n changes.\n- This skill supersedes the former `pikku-n8n-addon-map` and `pikku-n8n-code-translate`\n skills (folded into `references/addon-mapping.md` and `references/code-translation.md`).\n", "pikku-paraglide/SKILL.md": "---\nname: pikku-paraglide\ndescription: 'Generate typed, static enum-label maps for a Paraglide i18n frontend with `@pikku/paraglide`, and reconcile them against the database enum columns so a label can never silently drift from a DB value. Enum-valued labels live under a reserved `enum__<group>__<member>` message namespace; the generator emits `i18n-enum.gen.ts` typed `satisfies EnumLabel<DbEnum>`. TRIGGER when: labelling an enum/status/kind/role value in a Paraglide app, replacing a dynamic `mKey(...)`/`m[...]` lookup with a static map, wiring `@pikku/paraglide` into Vite, or reconciling i18n against `CHECK (col IN (...))` / Postgres enum columns. DO NOT TRIGGER for plain free-text UI copy (that is a normal `m.some_key()` message), backend errors, or logs.'\ninstallGroups: [core]\n---\n\n# Pikku Paraglide enum labels\n\n## Agent Operating Procedure\n\nUse this as an execution checklist, not reference material.\n\n1. **Is the value an enum (a closed set — a status/kind/role/tag from a DB column or a fixed union)?** Then its label is a static map entry, never a dynamic lookup. Add `enum__<group>__<member>` keys to the catalog (`messages/en.json`) and read the value through the generated map: `group[value]()`.\n2. **Is the value free text or a one-off literal** (a heading, a button, a never-indexed label)? Then it is a normal Paraglide message — call `m.<key>()` directly. Do **not** invent an enum group for something that is always a literal.\n3. **Wire the generator** (`@pikku/paraglide/vite` or the CLI) so `i18n-enum.gen.ts` is regenerated from the catalog, and point it at the DB enums module (`enums.gen.ts`) so the maps are typed against the database.\n4. **Validate with the app's own `tsc`.** Reconciliation is enforced purely by types: a missing key or a dropped DB member is a compile error, and the deploy gate runs `tsc` before building. A clean `vite build` alone does not type-check.\n\n## The rules that don't change\n\n- **Never resolve an enum key dynamically.** No `mKey('status.' + value)`, no `m['enum__status__' + value]()`, no `mExists`/`mList` helpers. Dynamic keys can't be type-checked or tree-shaken. Everything is a static `m.<literal>()` reference, generated into the map.\n- **The `enum__<group>__<member>` namespace.** `__` separates the prefix / group / member segments; a single `_` joins words *within* a segment (`enum__booking_status__form_received`). The prefix (`enum`) and separator (`__`) are configurable but leave them at the defaults.\n- **Members must be valid JS identifiers.** Spell out leading digits — `two_guests`, not `2_guests`. The generator quotes an invalid member as a fallback but warns you to rename it.\n- **`asI18n(...)` is only for opaque server data** (names, slugs, ids returned from the API). Never `asI18n()` a hardcoded English string or an enum value — an enum value goes through its label map.\n\n## The generated module\n\n`i18n-enum.gen.ts` is **AUTO-GENERATED — do not edit.** It exports, per enum group:\n\n```ts\nimport { m } from './messages.js'\nimport type { I18nString } from '@pikku/react'\nimport type { BookingStatus } from '#pikku/db/enums.gen' // when reconciled\n\nexport type I18nMessage = () => I18nString\nexport type EnumLabel<E extends string> = Record<E, I18nMessage>\n\nexport const bookingStatus = {\n enquiry: m.enum__booking_status__enquiry,\n reserved: m.enum__booking_status__reserved,\n confirmed: m.enum__booking_status__confirmed,\n} satisfies EnumLabel<BookingStatus>\nexport type BookingStatusKey = keyof typeof bookingStatus\n```\n\n- Each value is an `I18nMessage` — a `() => I18nString` accessor. **Call it at render time** so the label tracks the active locale.\n- App code: `import { bookingStatus } from './i18n/i18n-enum.gen'` then `bookingStatus[value]()`.\n- For an open server value, gate it: `value in bookingStatus ? bookingStatus[value as BookingStatusKey]() : asI18n(value)`.\n\n### Module-scope hazard — store the accessor, don't call it\n\nA label used in a config built at module load (nav items, column defs) must hold the **accessor**, not the result — calling `m.foo()` at module scope freezes the label to the locale that was active at import:\n\n```ts\n// nav.config.ts\nconst items = [{ label: m.common__nav__items__dashboard /* ← reference */ }]\n// at render: <span>{item.label()}</span> // ← call here\n```\n\n## Reconciliation against the database\n\nThe DB column is the real source of truth for what an enum can be. The pikku CLI's db codegen emits a bare unions module — `.pikku/db/enums.gen.ts` — covering **both** Postgres native enums and SQLite `CHECK (col IN ('a','b',…))` constraints:\n\n```ts\nexport type BookingStatus = 'enquiry' | 'reserved' | 'confirmed' | 'ended' | 'cancelled'\n```\n\nPoint `@pikku/paraglide` at that file (`enumsFile`) and each catalog group whose member set **exactly matches** a DB enum is typed `satisfies EnumLabel<DbEnum>`. The label map then **is** the reconciliation — no separate assertion:\n\n- catalog drops a DB member, or `en.json` is missing the key → `m.enum__…` doesn't exist / `Record<DbEnum,…>` isn't exhaustive → **`tsc` error naming the gap**.\n- a DB enum with **no** catalog group → `unmatchedDbEnums: 'emit'` (default) generates a label map referencing `enum__<table>_<column>__<member>` keys, so `tsc` tells you exactly which keys to add; `'warn'` only reports it.\n- a group with a member the DB lacks (a *derived* UI state, e.g. a `waitlisted` view of a `pending` row) → a drift warning. Make that a **standalone `m.<key>()` message**, not an enum member — the enum group must mirror the DB column exactly.\n\nLabelling an enum that's never rendered costs nothing: Paraglide compiles only the messages actually referenced, so unused labels are tree-shaken away. So label every DB enum; don't add an opt-out.\n\n**To make a column an enum**, give it a closed domain in the migration so codegen can see it:\n- SQLite: `status TEXT NOT NULL CHECK (status IN ('enquiry','reserved','confirmed'))`\n- Postgres: a native `CREATE TYPE … AS ENUM (…)` column.\n\n## Wiring\n\n### Vite (dev + build)\n\nPlace `paraglideEnums` **after** `paraglideVitePlugin` (the generated file imports the compiled `m`). It regenerates on catalog/enums edits and only writes on change, so it never loops HMR.\n\n```ts\nimport { paraglideVitePlugin } from '@inlang/paraglide-js'\nimport { paraglideEnums } from '@pikku/paraglide/vite'\n\nexport default defineConfig({\n plugins: [\n paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),\n paraglideEnums({\n catalog: './messages/en.json',\n outFile: './src/i18n/i18n-enum.gen.ts',\n enumsFile: './packages/functions/.pikku/db/enums.gen.ts', // reconcile against the DB\n // enumsImport: '#pikku/db/enums.gen', // explicit specifier; defaults to a relative path\n }),\n ],\n})\n```\n\n### CLI (CI / non-Vite), run right after `paraglide-js compile`\n\n```sh\n# paraglide-enums <catalog.json> <out.gen.ts> [messagesImport] [enums.gen.ts]\nparaglide-enums ./messages/en.json ./src/i18n/i18n-enum.gen.ts ./messages.js ./packages/functions/.pikku/db/enums.gen.ts\n```\n\n`i18n-enum.gen.ts` is generated — gitignore it once the plugin/CLI runs in the build.\n\n## What NOT to do\n\n- Don't write `mKey`/`mList`/`mExists` or any `m[expr]()` dynamic lookup — every enum label is a static generated reference.\n- Don't introduce a literal-key indirection helper (`k('approve_enquiry')`); a literal is `m.approve_enquiry()` directly.\n- Don't call `m.foo()` at module scope for config built at import time — store `m.foo` and call it at render.\n- Don't put an extra UI-only member into an enum group to match a derived state — make it a standalone message and keep the group an exact mirror of the DB column.\n- Don't hand-edit `i18n-enum.gen.ts` or `enums.gen.ts` — fix the catalog / the migration and regenerate.\n", "pikku-permissions/SKILL.md": "---\nname: pikku-permissions\ndescription: >-\n Use when adding authorization checks to Pikku functions — pikkuPermission, pikkuAuth, scopes and\n defineScope, per-function permissions, global permissions, or understanding the scope/OR/AND\n gating logic. TRIGGER when: user wants to restrict who can call a function, check resource\n ownership, add role-based or scope-based access, declares or grants scopes, hits\n MissingScopeError, or asks where permission checks belong. DO NOT TRIGGER when: user asks about\n middleware or request interception (use pikku-middleware), authentication strategies (use\n pikku-security), or session management.\ninstallGroups: [core]\n---\n\n# Pikku Permissions\n\n## The Rule\n\n**ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**\n\nThis includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func` and is visible to the inspector, so the gate is declared rather than buried — which is what lets `pikku info permissions` and an audit see it at all. Alongside it sits `scopes` (see below) for grant-based gating; between them they are where Pikku enforces authorization. The one sanctioned exception is `permissionsInBody`, covered at the end.\n\n```typescript\n// CORRECT\nexport const deleteBook = pikkuFunc({\n func: async ({ db }, { bookId }) => {\n await db.deleteBook(bookId)\n },\n permissions: {\n owner: isBookOwner, // ← authorization here\n },\n})\n\n// WRONG — permission check inside func body\nexport const deleteBook = pikkuFunc({\n func: async ({ db }, { bookId }, { session }) => {\n if (!session) throw new UnauthorizedError() // ← never do this\n await db.deleteBook(bookId)\n },\n})\n```\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info permissions --verbose` and `pikku info functions --verbose` to understand what permissions are already defined and applied.\n2. Define permission checkers in a `src/permissions.ts` or domain-specific `src/lib/*-permissions.ts` file.\n3. Apply them via the `permissions` field on the function. For an app-wide baseline that every function must additionally satisfy, use `addGlobalPermission`.\n4. Validate: run `pikku all --tsc` to confirm permission checker signatures are correct.\n\n## Permission Factories\n\n### `pikkuAuth(fn)` — Session-Only Checks\n\nUse for checks that read the session but need no request data — and that assert\nsomething **beyond** merely having a session (a flag, a tier, a claim).\n\n```typescript\nimport { pikkuAuth } from '#pikku'\n\n// Good: a real gate on the session's contents, not just its existence.\nexport const isVerified = pikkuAuth(\n async (_services, session) => !!session?.emailVerified\n)\n```\n\n**Do NOT write an \"is signed in\" permission.** A checker that just returns\n`!!session` is not authorization — it re-checks authentication, which the\nfunction already enforces. A function that needs a signed-in user sets\n`auth: true` (the default for `pikkuFunc`); it does not also carry a\n`permissions: { signedIn }`.\n\n```typescript\n// WRONG — redundant with auth: true; adds a permission that gates nothing.\nexport const isSignedIn = pikkuAuth(async (_s, session) => !!session)\npikkuFunc({ auth: true, permissions: { signedIn: isSignedIn }, /* ... */ })\n\n// RIGHT — auth: true already requires the session; permissions are for capability.\npikkuFunc({ auth: true, /* ... */ })\n```\n\nA permission answers \"*may this user do this?*\" (role, ownership, tier) — never\n\"*is there a session?*\".\n\n### `pikkuPermission(fn)` — Data-Aware Checks\n\nUse when authorization depends on the actual request data (e.g., resource ownership).\n\n```typescript\nimport { pikkuPermission } from '#pikku'\n\nexport const isBookOwner = pikkuPermission(\n async ({ db }, { bookId }, { session }) => {\n const book = await db.getBook(bookId)\n return book?.authorId === session?.userId\n }\n)\n\nexport const hasBookAccess = pikkuPermission(\n async ({ db }, { bookId }, { session }) => {\n return await db.hasAccess(session?.userId, bookId)\n }\n)\n```\n\n## OR / AND Logic\n\n```typescript\npermissions: {\n verified: isVerified, // OR: verified users can access\n owner: isBookOwner, // OR: owners can access\n reviewer: [isVerified, hasBookAccess], // AND: both must pass\n}\n// Logic: verified OR owner OR (isVerified AND hasBookAccess)\n```\n\nGroups are OR'd. Entries within a group array are AND'd.\n\n## Where to Apply Permissions\n\n### Per-Function (preferred)\n\n```typescript\nexport const deleteBook = pikkuFunc({\n func: async ({ db }, { bookId }) => {\n await db.deleteBook(bookId)\n },\n permissions: {\n verified: isVerified,\n owner: isBookOwner,\n },\n})\n```\n\n### Global (`addGlobalPermission`) — App-Wide AND Gate\n\nA global permission is an app-wide baseline that **every** function must additionally pass. It is an independent AND gate: it can only ever *narrow* access — it never grants access a function's own `permissions` would deny.\n\n```typescript\nimport { addGlobalPermission } from '#pikku'\n\naddGlobalPermission([isEmployee]) // every function now also requires an employee session\n```\n\nMultiple `addGlobalPermission` calls accumulate and are AND'd together.\n\n> Wire-, tag-, and HTTP-route-level permissions (`addHTTPPermission`, `addTagPermission`, and a `permissions` field on HTTP/channel/MCP wirings) were **removed in #972**. Permissions now live only on the function definition, plus the optional global gate. Tags are organizational only — use tag/HTTP *middleware* (`addTagMiddleware`, `addHTTPMiddleware`) for cross-cutting request handling, not authorization.\n\n## Scopes — the AND Gate Above Permissions\n\nScopes answer \"what was this session granted?\" before permissions ask \"may this\nuser do this to this resource?\". They are AND-ed: every scope listed must be\nheld. Because they are checked first and fail closed, a scope can only ever\n*narrow* access — it never grants what `permissions` would deny.\n\nDeclare the scope tree once with `defineScope`. The body is a no-op that\ntree-shakes away; the CLI reads the call by AST and generates a `ScopeId` union,\nso a function naming an undeclared scope fails the build rather than silently\ngating on nothing.\n\n```typescript\n// src/scopes.ts\nimport { defineScope } from '#pikku'\n\ndefineScope({\n admin: {\n displayName: 'Administration',\n description: 'Administrative access',\n scopes: {\n invoices: {\n description: 'Invoice management',\n scopes: {\n create: { description: 'Create invoices' },\n void: { description: 'Void invoices' },\n },\n },\n },\n },\n billing: {},\n})\n```\n\nEvery node is grantable, keyed by segment: the above yields `admin`,\n`admin:invoices`, `admin:invoices:create`, `admin:invoices:void` and `billing`.\nScopes may be declared across more than one file — the declarations merge.\n\n```typescript\nexport const voidInvoice = pikkuFunc({\n scopes: ['admin:invoices:void'],\n permissions: { owner: isInvoiceOwner },\n func: async ({ db }, { invoiceId }) => { ... },\n})\n```\n\nA grant satisfies a required scope if it is the scope itself, an ancestor of it,\nor a wildcard at any level — so a session holding `admin` satisfies\n`admin:invoices:void`, and `admin:*` does too. A missing scope throws\n`MissingScopeError` naming the first one that failed.\n\n`scopes` requires a session and so is unavailable on `pikkuSessionlessFunc`:\nscopes fail closed, an anonymous caller holds none, and a sessionless function\nwith scopes would reject every caller it exists to serve. Gate those with\n`permissions`, which receive the optional session and may pass anonymous.\n\n## The Three Gates\n\nAuthorization is three independent gates, evaluated in this order, all of which must pass:\n\n1. **Scopes** (`scopes`) — AND'd, checked before input validation. Fails closed.\n2. **Global permissions** (`addGlobalPermission`) — AND'd together. A broad baseline that can only narrow access.\n3. **The function's own `permissions`** — OR'd groups (OR-of-ANDs), as above.\n\nThe gates are independent: a broad global (e.g. `isEmployee`) can **never** satisfy an admin-only function's own requirement. Each function still enforces its own `scopes` and `permissions` in full.\n\n## The Sanctioned Exception: `permissionsInBody`\n\nA few checks genuinely cannot be expressed as a permission — verifying a webhook\nsignature, a signed token, or an invite code, where the \"identity\" arrives in the\npayload and there is no session to check. For those, declare\n`permissionsInBody: true` on the function and keep the check in the body.\n\n```typescript\nexport const handleStripeWebhook = pikkuSessionlessFunc({\n permissionsInBody: true,\n auth: false,\n func: async ({ stripe }, data, { http }) => {\n stripe.webhooks.constructEvent(data.raw, http.request.header('stripe-signature'), secret)\n // ...\n },\n})\n```\n\nThis is a last resort, and it is purely declarative — it grants nothing and\nenforces nothing. Its only job is to tell the auditor that this function's\napparent openness is deliberate, so asserting it falsely disables the very check\nthat would have caught the mistake. It requires `\"allow\": { \"permissionsInBody\": true }`\nin `pikku.config.json`, which keeps the decision visible at the project level.\nPrefer `permissions` whenever the check can be expressed as one — they are\ndeclared, inspectable, and reusable.\n\n## Complete Example\n\n```typescript\n// src/permissions.ts\nimport { pikkuAuth, pikkuPermission } from '#pikku'\n\nexport const isVerified = pikkuAuth(\n async (_services, session) => !!session?.emailVerified\n)\n\nexport const isOrgMember = pikkuPermission(\n async ({ db }, { orgId }, { session }) => {\n return await db.isMember(session?.userId, orgId)\n }\n)\n\n// src/functions/org.function.ts\nexport const deleteOrg = pikkuFunc({\n func: async ({ db }, { orgId }) => {\n await db.deleteOrg(orgId)\n },\n permissions: {\n verified: isVerified,\n owner: [isVerified, isOrgMember],\n },\n})\n```\n\n## After Changes\n\n```bash\npikku all # regenerate if wirings changed\npikku all --tsc # regenerate, then verify permission checker types (fails on type errors)\n```\n", "pikku-pino/SKILL.md": "---\nname: pikku-pino\ndescription: >-\n Use when setting up structured logging with Pino in a Pikku app. Covers PinoLogger setup and log\n levels. TRIGGER when: code uses PinoLogger, user asks about structured logging, Pino, or\n @pikku/pino. DO NOT TRIGGER when: user asks about ConsoleLogger (use pikku-services) or general\n service setup.\n---\n\n# Pikku Pino (Structured Logging)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/pino` provides structured JSON logging via [Pino](https://getpino.io/). Implements the `Logger` interface from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/pino\n```\n\n## API Reference\n\n### `PinoLogger`\n\n```typescript\nimport { PinoLogger } from '@pikku/pino'\n\nconst logger = new PinoLogger()\n```\n\nNo constructor parameters. Creates a Pino logger instance.\n\n**Properties:**\n\n- `pino: pino.Logger` — Access the underlying Pino instance for advanced config.\n\n**Methods:**\n\n- `setLevel(level: LogLevel): void` — Set minimum log level.\n- `info(messageOrObj: string | Record<string, any> | Error, ...meta): void`\n- `warn(messageOrObj: string | Record<string, any> | Error, ...meta): void`\n- `error(messageOrObj: string | Record<string, any> | Error, ...meta): void`\n- `debug(message: string, ...meta): void` — string only; the object form is not accepted here\n\nEvery argument, first and trailing, is `Safe<>`-guarded. A `SecretValue` nested\nanywhere in what you log collapses the call to `never` and it stops compiling.\nAn unrevealed secret would print as `[secret]` regardless — the guard is what\nmakes logging one a deliberate act rather than an accident.\n\n`setLevel` maps Pikku's `LogLevel` enum onto Pino's own level strings, so pass\nthe enum (or its name) rather than a raw Pino level.\n\n## Usage Patterns\n\n### Basic Setup\n\n```typescript\nimport { PinoLogger } from '@pikku/pino'\n\nconst logger = new PinoLogger()\nlogger.setLevel('debug')\n```\n\n### With Pikku Services\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n return { config, logger }\n})\n```\n\n### Accessing Underlying Pino\n\n```typescript\nconst logger = new PinoLogger()\nlogger.pino.child({ module: 'auth' }).info('Token verified')\n```\n", "pikku-product-second-opinion/example/sample-report.md": "# Your app, in plain English — and where it could get better\n*A second opinion on the competitor-tracking system*\n\n> Worked example for the pikku-product-second-opinion skill. Shows the voice and the\n> layered structure on one real area (competitor tracking), drawn from a\n> pikku-software-archaeology blueprint + parity report. A full report would repeat\n> Part 2 for each major area, and cover every significant technology bet — not just\n> the one shown here.\n\n**How to read this:** no technical background needed. I'll explain what you have,\nwhat's solid, and what I'd change — and for each change, what it costs and what\nit buys you.\n\n## Part 1 — The short version\n\n**What you have.** A competitive-intelligence app: it watches your competitors'\nwebsites, spots meaningful changes (pricing, hiring, product updates), summarizes\nthem, and feeds your briefings and dashboards so your team knows first.\n\n**The headline.**\n- The hard part — reading messy websites and telling a real change from noise — is built well.\n- Until recently the app wasn't re-checking sites on its own at all *(now fixed)*.\n- When it does spot a change, the follow-up work only happens if someone clicks a button — so your dashboards can quietly go stale while looking current.\n\n**If it were me, this is the order I'd tackle things:**\n\n| Fix | Why it matters to you | Effort | Payoff |\n|---|---|---|---|\n| Turn on automatic checking | Sites weren't refreshing themselves | *Done* | High |\n| Make the follow-up automatic | Stops your intelligence going stale unnoticed | Medium | High |\n| Make failures visible | Problems surface instead of hiding | Small | Medium |\n\n---\n\n## Part 2 — Area by area\n\n### Competitor tracking\n\n**What this does.** Watches your competitors' sites for you and turns meaningful\nchanges into summaries your team can act on.\n\n**How it works today.** Like a clipping service: on a timer, the app re-reads\neach competitor's site, compares it to last time, decides whether anything\n*meaningful* changed (it ignores trivial edits), and writes up a summary when\nsomething real happens.\n\n**What's working.** The expensive, valuable part is solid — the app is genuinely\ngood at reading messy sites, separating real changes from noise, and summarizing\nthem. Keep it.\n\n**What's holding you back.**\n- **The automatic checking wasn't switched on.** The machinery existed but nothing\n pulled the trigger, so sites weren't refreshing on their own. What it means for\n you: your \"live\" intelligence wasn't live. Severity: Urgent. Effort: Small.\n *(Already fixed.)*\n- **The follow-up is manual.** When a change is found, updating your briefings and\n comparisons doesn't happen on its own — someone has to click \"regenerate.\" What\n it means for you: if nobody clicks, the dashboard shows old information while\n looking up to date, and you can't trust it. Severity: Serious. Effort: Medium.\n\n**How I'd do it differently — and why it's worth it.** Make the whole chain\nfinish as one task: when a change is found, the briefings and comparisons update\nautomatically as part of the same job, so \"done\" means \"your intelligence is\nactually current.\" And a failed step should show as a visible error, not vanish.\nThis is a **rewire, not a rebuild** — the valuable machinery stays; I'm connecting\npieces that already sit next to each other. Outcome: **more reliable, fewer\nsurprises** — which is the entire promise of the product.\n\n---\n\n### The technology bets\n\n**Pikku — the framework I'm suggesting you rebuild onto.** *Buys you:* one way to\nwrite a capability and drive it from anywhere — web, timers, background jobs,\nassistants — so the tracking rule is written once instead of three times, which is\nexactly the sprawl above. *Costs you:* it hasn't shipped a stable 1.0 (it's 0.12.x;\n0.13 is the first release promising backwards compatibility), so until then\nupgrades can break you — pin the version and budget for upgrade work. Its community\nand hiring pool are far smaller than the mainstream default's. That's normal for a\nyoung framework and survivable, but it's a real cost and it's yours to weigh.\n*Usually:* worth it when the problem is genuinely sprawl, as it is here — and worth\nwaiting if nobody has capacity to own upgrades.\n\n---\n\n## Part 3 — The fine print\n\n**How confident am I.** High on both problems — I can see them directly. The fix\nis a well-understood pattern rather than an experiment, though I'd want one live\ntest against your real data before calling it done.\n", "pikku-product-second-opinion/README.md": "# pikku-product-second-opinion\n\nTurns a `pikku-software-archaeology` blueprint into a **plain-language report for a\nnon-technical owner** — a founder/PM stuck with an app they didn't build.\nExplains how it works and how it could be better, in business terms.\n\n```\nExisting repo → pikku-software-archaeology → .knowledge/ blueprint → pikku-product-second-opinion → founder report\n (facts) (extract) (machine-readable) (translate + advise) (markdown + web page)\n```\n\n## The split from pikku-software-archaeology\n\n- **pikku-software-archaeology** extracts *facts* into `.knowledge/` for a machine (Pikku) to rebuild from. Engineer/generator audience.\n- **pikku-product-second-opinion** reads that blueprint and writes an *opinionated report* for a human to decide from. Non-technical audience.\n\nOne extracts; one advises. This skill consumes the other's output — it doesn't re-read the code.\n\n## What the report is calibrated to (locked by the author)\n\n- **Layered depth** — a one-page executive summary, then a section per major area for anyone who wants detail.\n- **Direct but fair tone** — names problems plainly, always with why-it-matters and credit for what's good.\n- **Both formats** — a markdown copy in the repo plus a clean, shareable web page (rendered via the `artifact-design` skill).\n\n## The rules that make it work\n\n1. **Translate, don't dump.** Every technical concept becomes a business outcome or a plain description. The jargon→plain table is in `SKILL.md`.\n2. **Every problem carries impact + severity + effort.** A problem with no \"what it means for you\" doesn't ship.\n3. **Always credit what works.** All-criticism reports get dismissed.\n4. **Argue improvements in business outcomes** (more reliable / faster / cheaper / safer / easier to hand off), and say whether each is a cheap **rewire** or an expensive **rebuild** — never recommend a rewrite just because the code is messy.\n5. **Mark confidence.** Certain and \"I'd need to check\" are different sentences.\n6. **Cover the frontend and the other ways the app is used** when the blueprint has them — walk the screens as a journey, call out consistency, and flag the custom-logic pieces (charts/tables/editors) as the real work vs the cheap standard pieces. Name the ways the product can be driven (people/web, developers/API+SDK, AI agents/MCP, power users/CLI) — often a genuine strength.\n7. **Give honest technology tradeoffs — both sides.** Every stack bet (framework, auth, hosting, key libraries) gets what-it-buys AND what-it-costs in business terms, tied to the founder's stage/goals. Don't cheerlead, don't trash, and **don't soften the disadvantages**. The app's own bets are derived from the blueprint (`architecture.json`/`integrations.json`/`frontend.json` + the repo's manifest) — never from a list in the skill, because a verdict you could write before reading the blueprint isn't a second opinion. Separately, and only when a rebuild is actually being recommended, the target stack (Pikku, Better Auth, TanStack Start) gets the *same* both-sides treatment with cons first-class — pinning someone's dependency for being pre-1.0 while staying quiet about the replacement being pre-1.0 too is a pitch, not an opinion.\n\n## Files\n\n```\npikku-product-second-opinion/\n├── SKILL.md # method + voice rules + red flags\n├── README.md # this file\n├── references/report-template.md # the layered structure to fill in\n└── example/sample-report.md # worked example (competitor-tracking area, founder voice)\n```\n", "pikku-product-second-opinion/references/report-template.md": "# Report template\n\nFill this in. Keep sentences short. Lead every point with what it means for the\nreader. Delete any section that would be empty rather than padding it.\n\n---\n\n# {App name}, in plain English — and where it could get better\n*A second opinion on {scope: the whole app / the competitor-tracking system / …}*\n\n**How to read this:** no technical background needed. Part 1 is the summary — if\nyou read nothing else, read that. Parts 2–3 go area by area for anyone who wants\ndetail.\n\n## Part 1 — The short version\n\n**What you have.** {2–3 sentences: what the product does, who uses it.}\n\n**The headline.** {3–5 bullets, one plain line each — the biggest risks and\nopportunities. No jargon.}\n\n**If it were me, this is the order I'd tackle things:**\n\n| Fix | Why it matters to you | Effort | Payoff |\n|---|---|---|---|\n| {…} | {business impact} | Small/Medium/Large | High/Medium/Low |\n\n---\n\n## Part 2 — Area by area\n\n### {Area name, in business terms — e.g. \"Competitor tracking\"}\n\n**What this does.** {the capability, as the business experiences it}\n\n**How it works today.** {a plain walkthrough — a small story beats a diagram}\n\n**What's working.** {genuine credit — the parts that are solid and worth keeping}\n\n**What's holding you back.**\n- **{Problem in plain terms}.** What it means for you: {business impact}.\n Severity: {Minor / Worth fixing / Serious / Urgent}. Effort to fix: {Small /\n Medium / Large}.\n\n**How I'd do it differently — and why it's worth it.** {the better design in\noutcomes: more reliable / faster to change / cheaper / safer / easier to hand\noff. Say whether it's a cheap rewire or an expensive rebuild.}\n\n{repeat per area}\n\n### The technology bets\n\n{One entry per significant choice — framework, sign-in, hosting, database, key\nlibraries — AND anything a rebuild would move them ONTO. Each gets both sides.}\n\n**{Technology}.**\n- *Buys you:* {in business terms}\n- *Costs you:* {in business terms — bills, hiring, shipping speed, upgrade work,\n the risk of betting on something young. Don't soften it. If it hasn't shipped a\n stable 1.0, say so and say what that means: pin the version, budget upgrades.}\n- *Usually:* {recommendation tied to their stage — normally \"keep it, watch this\"}\n\n{The same bar applies to anything you're recommending they move to. A stack you\npropose with no cons listed is a pitch, not a second opinion.}\n\n---\n\n## Part 3 — The fine print (optional)\n\n**How confident am I.** {where you're certain vs guessing; what you'd verify\nagainst real data before committing}\n\n**A few words explained.** {glossary — only terms that couldn't be avoided}\n", "pikku-product-second-opinion/SKILL.md": "---\nname: pikku-product-second-opinion\ndescription: 'Use when a non-technical owner (founder, PM, operator) wants a plain-language report on an app they hold but did not build — explaining how it works and how it could be better. Reads the .knowledge/ blueprint from pikku-software-archaeology and produces a layered, jargon-free report that credits what works, names what does not (with business impact + effort), and argues an opinionated better design. TRIGGER: \"explain how my app works\", \"what would you do differently\", \"review my app for a non-technical audience\", \"I inherited/am stuck with an agency-built app\", \"is this built well?\". DO NOT TRIGGER for: extracting the machine-readable blueprint itself (use pikku-software-archaeology), or an engineer-facing technical code review.'\ninstallGroups: [fabric]\n---\n\n# Product Second Opinion\n\n## Overview\n\nTurn an extracted product blueprint into a **report a non-technical owner can act on**. Two jobs, in one voice: (1) explain, in plain language, how the app they're stuck with actually works; (2) give an honest, opinionated second opinion — what's solid, what's holding them back, and how you'd build it better, argued in business outcomes, not architecture.\n\nThe reader is a founder/PM/operator, not an engineer. If they finish a section and don't know what it means for their business or what to do about it, the report failed — no matter how correct it is.\n\n**REQUIRED INPUT:** the `.knowledge/` blueprint produced by **pikku-software-archaeology**. If none exists, run that skill first — this one consumes its output (`product.json`, `domains.json`, `workflows.json`, `gaps.json`, `invariants.json`, `migration.json`, and any `parity-*.md`), it does not re-derive facts from the code. When the optional consumer-surface files are present (`interfaces.json`, `frontend.json`, `frontend-routes.json`, `frontend-components.json`), cover them too — see \"The frontend and the other ways your app is used\" and \"Technology choices\" below.\n\n## The cardinal rule: translate, don't dump\n\nEvery technical concept becomes a business outcome or a plain-language description. Never make the reader learn your vocabulary. If a term is unavoidable, define it in one clause the first time — but prefer describing the *effect* and skipping the term entirely.\n\n| Don't write | Write instead (describe the effect) |\n|---|---|\n| queue / worker / job | \"a background task that runs on its own\" |\n| workflow | \"a multi-step task that resumes where it left off if interrupted\" |\n| API / endpoint / route | \"something the app (or another tool) can ask it to do\" |\n| webhook | \"an automatic message the app sends to another tool when something happens\" |\n| event | \"a signal that something happened, that other parts can react to\" |\n| cron / scheduler | \"a timer that runs something on a schedule\" |\n| schema / migration | \"the shape of your stored data\" / \"a change to how data is stored\" |\n| auth / session / token | \"how the app knows who you are and what you're allowed to do\" |\n| refactor / rewire | \"reorganizing the inside without changing what it does\" |\n| cache | \"a saved copy kept around for speed\" |\n| race condition | \"two things happening at once and stepping on each other\" |\n| component | \"a reusable piece of the screen (a button, a chart, a table)\" |\n| route / page | \"a screen in the app\" |\n| design system / component library | \"the shared kit of screen pieces that keeps everything looking consistent\" |\n| SSR / SPA / rendering | \"how pages get built and shown\" (only mention if it affects speed or SEO) |\n| MCP server | \"a way for AI assistants to use your app's data and actions directly\" |\n| SDK | \"a ready-made toolkit so other developers can build on your app\" |\n| CLI | \"a way to drive the app by typing commands (for power users / automation)\" |\n| theme token / design variable | \"a single setting (like your brand color) reused everywhere, so you change it once\" |\n| modal / drawer | \"a pop-up box\" / \"a slide-out panel\" |\n\nWhen in doubt, say what the *user or the business* experiences, not what the machine does.\n\n## Report structure (layered — skim or dive)\n\nWrite these three parts in order. A reader can stop after Part 1.\n\n**Part 1 — Executive summary (one page).**\n- *What you have*: 2–3 sentences — what the product does and who uses it.\n- *The headline*: the 3–5 biggest risks/opportunities, one plain line each.\n- *Recommended order*: a table (Fix | Why it matters | Effort | Payoff). This is the part they act on.\n\n**Part 2 — One section per major area** (drive the areas from `domains.json`; skip domains with nothing worth saying). Each section follows this shape (see `example/sample-report.md`):\n- *What this does* — the capability in business terms.\n- *How it works today* — a plain walkthrough, ideally as a small story (\"on a timer, the app re-reads each site, compares…\").\n- *What's working* — genuine credit. Never skip this; a report that's all criticism gets dismissed.\n- *What's holding you back* — each problem MUST carry: **what it means for you** (business impact), **severity** (Minor / Worth fixing / Serious / Urgent), and **effort** (Small / Medium / Large).\n- *How I'd do it differently — and why it's worth it* — the opinionated part. Argue the improvement in one of these business outcomes: **more reliable / fewer surprises**, **faster to add features**, **cheaper to run**, **safer / less risk**, **easier to maintain or hand off**. Be explicit whether it's a cheap rewire or an expensive rebuild.\n\n**Part 3 — Appendix.**\n- *How confident am I* — REQUIRED. Where you're certain vs guessing; what you'd verify against real data first. The blueprint carries confidence tiers — anything you're relaying from a `low`/`medium` entry, or from a reconstructed (`explicit: false`) event, says so here.\n- *Glossary* (optional) — only for any term that slipped through.\n\n## Rewire vs rebuild (say which)\n\nThe blueprint's `migration.json` tells you which is which — `mappings[]` is what survives (each with its `recommendation`), `dropped[]` is what goes. The reader needs to know because the cost is 10× different.\n- **Rewire** — the valuable machinery exists; you're connecting pieces or turning something on. Cheap, low-risk. (Most \"it should be automatic but isn't\" findings are this.)\n- **Rebuild** — the capability doesn't exist or is fundamentally wrong. Expensive, risky. Reserve the word for when it's true; founders hear \"rewrite\" and panic or overspend.\n\nNever recommend a full rewrite because the code is messy. Messy-but-working is a rewire-over-time story, not a bonfire.\n\n## The frontend and the other ways your app is used\n\nWhen the blueprint has the consumer-surface files, add these to the report — they're often where a founder's questions actually live (\"why does the app feel inconsistent?\", \"can partners build on this?\").\n\n**The screens (`frontend-*.json`) — one area section, founder-framed.**\n- *What a user can do* — walk the main screens as a journey, not a component list.\n- *Consistency* — is it built from one shared kit of screen pieces, or a patchwork? A consistent kit means changes are cheap and the app feels coherent; a patchwork means every change is bespoke and the look drifts. Say which, plainly.\n- *The expensive pieces* — this is the key frontend insight. Most of the screen is standard pieces that are cheap to rebuild or restyle. A **small number carry real custom logic** — a bespoke chart, a complicated data table, a drawing/drag interaction, a rich editor. Those are the parts that take real effort to move or change, and the ones most likely to break. Name them, say what they do, and flag them as the real work — so nobody assumes \"it's just screens, it'll be quick.\"\n- *Design consistency (the \"it looks a bit off\" problems)* — from the blueprint's design findings, call out broken patterns in plain terms and, crucially, why each matters and roughly what it costs to fix. Common ones and how to frame them:\n - **The same action behaves differently in different places** (a slide-out panel here, a pop-up box there for the same task). *Why it matters:* the app feels inconsistent and users have to re-learn each screen. *Fix:* pick one pattern and apply it everywhere — cheap.\n - **Colors/spacing are hardcoded instead of set in one place.** *Why it matters:* changing your brand color, or fixing contrast, means hunting through every screen instead of editing one setting — slow and error-prone. *Fix:* move them to shared \"design tokens\" — a small, high-leverage cleanup.\n - **The same element looks different from page to page** (buttons, headings, cards). *Why it matters:* reads as unpolished and erodes trust, especially in a paid product. *Fix:* one shared version of each, reused — cheap and makes every future change faster.\n These are almost always **cheap rewires with an outsized polish/trust payoff**, not rebuilds. Give each an effort (usually Small–Medium) and say the payoff is perceived quality + faster future changes. Do NOT design-nitpick without a reason — every design point needs a \"why it matters to you.\" And credit consistency where the app already has it.\n- Frame rebuild/restyle work as **rewire vs rebuild**: restyling standard pieces to a consistent kit is cheap; re-creating a custom-logic piece is real engineering.\n\n**How your app can be driven (`interfaces.json`) — usually a short, positive section.**\nExplain, in one line each, the ways the product can be used: people through the web, developers through an API or toolkit, AI assistants through a direct connection (MCP), power users through the command line. This is often a genuine strength worth naming — an app that agents and partners can build on is more valuable than one only humans can click. But be honest about `status`: a connection that exists but only does two things is a *start*, not a feature — say so.\n\n## Technology choices — the honest tradeoffs (don't be cheap on the cons)\n\nThe app made specific technology bets. The founder deserves to know what each bet *bought* and what it *costs* — in business terms, tied to their situation (early vs scaling, chasing enterprise deals or not, big team or two people). Present every significant choice as a genuine tradeoff with **both sides**. Never cheerlead a technology, and never trash one — but do not soften the disadvantages to sound positive. A report that only lists upsides is not honest and is not useful.\n\nRules:\n- For each notable choice (framework, auth, hosting, database, key libraries): **what it buys** and **what it costs**, both in plain business terms, then a recommendation tied to *their* stage and goals — usually \"keep it, here's what to watch\" rather than \"switch.\"\n- Tie cons to consequences the founder feels: vendor bills, security/breach liability, hiring difficulty, how fast they can ship, enterprise-sales blockers, the risk of betting on something young.\n- Distinguish \"younger / smaller community\" (a real, manageable risk) from \"wrong choice\" (rare). Most stack choices are defensible; the job is informed eyes-open, not alarm.\n- **Verify before you disparage.** \"Don't be cheap on the cons\" means ACCURATE cons, not invented ones. Do NOT label a technology immature, niche, or feature-poor from vibes, its name, or its age — check its actual adoption, maturity, and feature set first. And separate an **inherent tradeoff of an approach** (e.g. self-hosting anything means you run and secure it) from a **deficiency of a specific tool** (often false — the tool may be mature and full-featured). Overstating cons is as dishonest as hiding them.\n- **Hold your own recommendation to the same bar.** If \"how I'd do it differently\" lands on a specific stack — Pikku included — it gets the same both-sides treatment as everything else, cons first-class. Pinning someone's dependency for being pre-1.0 while not mentioning that the replacement is pre-1.0 too isn't a second opinion, it's a pitch.\n\n- **Derive the app's choices from the blueprint, never from a list in this file.** Read the stack off `architecture.json`, `integrations.json`, `frontend.json`, and the manifest the repo actually has (`package.json`, `Gemfile`, `go.mod`, …). Cover the bets that are *load-bearing for this product*: typically the framework, the auth/identity approach, the datastore, the hosting/deploy model, the payment and other critical vendor integrations, and anything the blueprint marks `replacementDifficulty: hard`. A choice earns a paragraph if switching it would be expensive, or if living with it constrains the business — not because it appears in some canonical list. If you could write the verdict before reading the blueprint, you are not giving a second opinion.\n\n### The choices *this* app made\n\nWhatever the blueprint shows. A Rails app's bets are Rails, Devise, Pundit, MySQL, Sidekiq, its ERP and payment vendors; a Go app's are different again. Fill in buys/costs/usually for each, from evidence. If a legacy choice is working fine, credit it and move on — \"boring and working\" is a feature, and the pressure to find something to say about a stack is exactly what produces dishonest reports.\n\nWorth naming when it applies: adopting one coherent system in place of hand-rolled, drifted machinery (several ways of deciding who's an admin, a bespoke token table, a back-door test login, home-grown crypto) is a real reliability-and-security upgrade, not a lateral swap — say so when the blueprint's `gaps.json` and `policies.json` show that sprawl.\n\n### The stack a rebuild would land on\n\nInclude this section **only if you are actually recommending a rebuild** onto it — and then give every part of it the same both-sides treatment you gave the app's own bets, per the \"hold your own recommendation to the same bar\" rule above. These are not choices the app made; they are choices you are proposing, which is exactly why their costs are the reader's to weigh. The Pikku target stack is Pikku + Better Auth + TanStack Start + Mantine; the framings below are reference material for the parts you actually recommend, not a script to recite.\n\n**Better Auth (self-hosted sign-in) — instead of a paid service like Auth0/Clerk.**\n- *Buys you:* a mature, battle-tested, **framework-agnostic** library with a deep first-class plugin catalog — two-factor auth, multi-tenancy/organizations, multi-session, rate limiting, Stripe subscription billing, an admin panel, API keys for partners/automation, single-sign-on — plus a plugin system to add more without forking. You keep your users in your own database (single source of truth, no per-user bill that grows with success), with full control of the auth flows, and you can run it embedded in the app or as a standalone self-hosted auth server. So self-hosting here means neither giving up features nor rolling your own security.\n- *Cleans up messy auth (often the biggest win):* adopting it consolidates the kind of hand-rolled, drifted auth that accumulates in an older codebase — several different ways of deciding who's an admin, a bespoke token table, a back-door test login, home-grown encryption — into **one coherent system**. If the blueprint shows a before (legacy, bespoke) and after (on Better Auth), point at it directly: the sprawl collapses into a single well-structured setup. A concrete reliability-and-security upgrade, not just a swap.\n- *Costs you (the honest tradeoff — operational, not security-implementation):* the auth flows and security practices are handled by the library, so this is NOT \"build secure auth from scratch.\" What self-hosting means is you **operate** it — hosting, upgrades, uptime, and incident response sit with your team, where a paid SaaS runs that for you and bundles hosted extras (bot/anomaly detection, leaked-password monitoring, vendor compliance certifications) you'd otherwise operate and document yourself. You trade a per-user bill and vendor ops for control, data ownership, and predictable cost.\n- *Usually:* a strong default for an independent product — mature, full-featured, framework-agnostic, and frequently a genuine cleanup of inherited auth. The real question is who owns operating it, not whether the tool is good enough.\n\n**TanStack Start (the web framework) — instead of the incumbent (Next.js).**\n- *Buys you:* modern, tidy developer experience; strong type-safety that catches whole classes of bugs before users see them; fast iteration; fine-grained control; deploys well to modern/edge hosting. The libraries underneath it — the TanStack ecosystem (Query, Router, Table) — are mature, battle-tested and everywhere in React.\n- *Costs you (the honest tradeoff — maturity of the framework itself):* separate the ecosystem from the framework. Query/Router/Table are mature; the framework that wraps them is younger, and **you must check its release stage on tanstack.com/start at the moment you write** — do not infer it from the npm version. `@tanstack/react-start` has been on 1.x since early 2025 because its major tracks the **Router** version line, so \"1.168.x\" says nothing about whether Start itself has shipped a stable 1.0. If it is still pre-1.0, the cost is pinning an exact version and budgeting for upgrade work as it settles. Either way it is newer than the incumbent (Next.js), which has the largest ecosystem — fewer ready-made templates and third-party examples, and a smaller (though growing) pool of developers who've used *this specific* framework, which can make hiring slightly slower.\n- *Usually:* a credible, modern choice on a mature foundation. Whether it also carries pinning-and-upgrade risk depends on the release stage you just checked — say which you found, rather than repeating either verdict from here.\n\n**Pikku (the framework a rebuild would land on) — instead of staying where you are.**\n- *Buys you:* one way to write a capability and drive it from anywhere — web, background jobs, timers, realtime, AI assistants, the command line — so a feature is written once instead of five times. Type-safe clients and the API spec fall out of the code rather than being hand-maintained until they drift. The sprawl an organically-grown app accumulates collapses into one shape a small team can hold in its head.\n- *Costs you (the honest tradeoff — it is younger than anything it would replace):* Pikku has **not shipped a stable 1.0** — at the time of writing it is 0.12.x, and 0.13 is the first release that promises backwards compatibility; check the published version rather than repeating this one. Until then upgrades can break you. In practice: pin your version, budget for upgrade work, and know that the community, the ready-made examples, and the pool of developers who have used it are all far smaller than the incumbent's — smaller than TanStack Start's, let alone Next.js's. Being pre-1.0 is normal for a young framework, and survivable, but it is a real cost and it is the reader's to weigh, not yours to skip.\n- *Usually:* worth it when the real problem is sprawl — many surfaces, hand-maintained glue, the same rule implemented three slightly different ways — and the team wants one shape instead of five. Harder to justify for an app that works and needs a few rewires: those are usually cheaper in place. If nobody has capacity to own upgrades, that's a real reason to wait.\n\nCheck these statuses before you write them up rather than repeating them from here — a framework's release stage moves, and the point is the current fact, not this example.\n\n## Delivery\n\nProduce **both**:\n1. A **markdown** report in the repo (e.g. `docs/reports/<app>-second-opinion.md`) — versioned, diffable.\n2. A **shareable web page**: load the **artifact-design** skill, then render the same report as one clean, print-friendly, theme-aware page they can send to a cofounder or the agency. Same content, nicer to read.\n\n## Red flags — you're writing the wrong report\n\n| Symptom | Fix |\n|---|---|\n| A technical term with no translation | Rephrase as the effect on the user/business, or cut the term. |\n| A problem with no \"what it means for you\" | Incomplete — add the business impact or delete it. |\n| All problems, no credit | You'll lose the reader's trust. Name what's genuinely good. |\n| A recommendation with no effort + payoff | Not decision-useful. Add both. |\n| \"Rewrite the app\" | Almost always wrong. Separate rewire (cheap) from rebuild (dear); lean on what `migration.json.mappings` says survives. |\n| A guess stated as fact | Mark confidence. \"I'm certain\" and \"I'd need to check\" are different sentences. |\n| Only listed the upsides of a technology choice | Not honest. Every bet has a cost — name it in business terms, don't soften it to sound positive. |\n| Recommended a stack (including ours) without its cons | You applied a maturity bar to their technology and exempted your own. Both sides, or cut the recommendation. |\n| Trashed a technology as \"the wrong choice\" | Equally lazy. Most choices are defensible; frame as tradeoff + \"what to watch,\" not a verdict. |\n| \"The frontend is just screens, it'll be quick\" | Wrong. The custom-logic pieces (charts, complex tables, editors) are real work — flag them separately from the cheap standard pieces. |\n| A design point with no \"why it matters\" | Taste, not advice. Tie every design finding to user perception (polish/trust) or maintenance cost (change-once vs hunt-everywhere), plus effort. |\n| Reads like a code review | Wrong audience. Would a founder know what to *do* after this paragraph? |\n\n## Relationship to pikku-software-archaeology\n\n`pikku-software-archaeology` = facts → `.knowledge/` blueprint, for a machine to rebuild from. **This skill** = blueprint → opinionated report, for a human to decide from. One extracts; one advises. Run archaeology first (or point this skill at an existing `.knowledge/`), then translate its `gaps.json` + `invariants.json` + `migration.json` into the business-language report above.\n", "pikku-queue/SKILL.md": "---\nname: pikku-queue\ndescription: >-\n Use when adding background job processing, async task queues, or distributed workers to a Pikku\n app. Covers wireQueueWorker, job enqueuing, progress tracking, retries, BullMQ and PgBoss\n adapters. TRIGGER when: code uses wireQueueWorker, user asks about background jobs, task queues,\n async processing, BullMQ, PgBoss, or job retries. DO NOT TRIGGER when: user asks about scheduled\n cron tasks (use pikku-cron) or event-driven triggers (use pikku-trigger).\ninstallGroups: [core]\n---\n\n# Pikku Queue Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions as background queue workers. Supports job control (progress, retry, discard), configurable concurrency, and type-safe job publishing.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireQueueWorker(config)`\n\n```typescript\nimport { wireQueueWorker } from '@pikku/core/queue'\n\nwireQueueWorker({\n name: string, // Queue name (unique identifier)\n func: PikkuFunc, // Worker function\n config?: {\n batchSize?: number, // Total worker concurrency\n prefetch?: number,\n pollInterval?: number, // ms\n visibilityTimeout?: number, // seconds\n lockDuration?: number, // ms\n drainDelay?: number, // seconds\n removeOnComplete?: number, // how many completed jobs to RETAIN (a count, not an age)\n removeOnFail?: number, // how many failed jobs to RETAIN\n maxStalledCount?: number,\n autorun?: boolean,\n groupConcurrency?: number | GroupConcurrencyConfig, // must not exceed batchSize\n },\n})\n```\n\nNot every adapter supports every option. Each adapter declares a\n`QueueConfigMapping`, and unsupported keys are dropped with a warning rather than\nsilently ignored — so check the startup logs if a setting appears to have no\neffect.\n\n`groupConcurrency` limits how many jobs run concurrently *per group* (jobs\ncarrying a `JobGroup` with an `id` and optional `tier`), so one noisy tenant\ncannot consume the whole worker:\n\n```typescript\ngroupConcurrency: { default: 2, tiers: { enterprise: 10 } }\n```\n\n### Wire Object (`wire.queue`)\n\nInside queue worker functions:\n\n```typescript\nwire.queue.updateProgress(progress: number | string | object) // Report progress\nwire.queue.discard(reason?: string) // Silently discard job (throws QueueJobDiscardedError)\nwire.queue.fail(reason?: string) // Mark job as failed\n```\n\n`updateProgress` is not limited to a 0-100 percentage — a string or an object\nlets a long job report a stage (\"rendering page 4/20\") that a dashboard can show\ndirectly.\n\n### Job Publishing\n\n```typescript\nconst jobId = await queue.add(queueName, data, options?)\n```\n\nOptions:\n\n```typescript\n{\n retryAttempts?: number, // Max retry attempts\n retryDelay?: number, // Base delay in ms\n retryBackoff?: 'linear' | 'exponential' | 'fixed',\n deadLetterQueue?: string, // Where exhausted jobs land\n messageRetention?: number,// Seconds\n priority?: number, // Higher numbers run first\n fifo?: boolean,\n timeout?: number, // ms\n delay?: number, // ms before the job becomes eligible\n}\n```\n\n## Usage Patterns\n\n### Basic Queue Worker\n\n```typescript\nconst processReminder = pikkuSessionlessFunc({\n title: 'Process Reminder',\n func: async ({ db, emailService }, { todoId, userId }) => {\n const todo = await db.getTodo(todoId)\n await emailService.sendReminder(userId, todo)\n return { sent: true }\n },\n})\n\nwireQueueWorker({\n name: 'todo-reminders',\n func: processReminder,\n})\n```\n\n### Job Control (Progress, Discard, Fail)\n\n```typescript\nconst processReminder = pikkuSessionlessFunc({\n title: 'Process Reminder',\n func: async ({ db }, { todoId }, wire) => {\n await wire.queue.updateProgress(25)\n\n const todo = await db.getTodo(todoId)\n if (!todo) {\n await wire.queue.discard('Todo not found')\n return\n }\n\n if (todo.completed) {\n await wire.queue.fail('Todo already completed')\n return\n }\n\n await wire.queue.updateProgress(100)\n return { sent: true }\n },\n})\n```\n\n### Retries & Configuration\n\n```typescript\nwireQueueWorker({\n name: 'todo-reminders',\n func: processReminder,\n config: {\n batchSize: 5,\n removeOnComplete: 100,\n },\n})\n\n// Enqueue with retry options\nconst jobId = await queue.add(\n 'todo-reminders',\n {\n todoId: 'abc-123',\n userId: 'user-456',\n },\n {\n priority: 10,\n delay: 5000,\n retryAttempts: 3,\n retryBackoff: 'exponential',\n retryDelay: 1000,\n }\n)\n```\n\n### Type-Safe Queue Publishing\n\nAfter `npx pikku all`:\n\n```typescript\nimport { PikkuQueue } from '#pikku/pikku-queue.gen.js'\n\nconst queue = new PikkuQueue(queueService)\n\nconst jobId = await queue.add('todo-reminders', {\n todoId: 'abc-123',\n userId: 'user-456',\n})\n\nconst job = await queue.getJob('todo-reminders', jobId)\nconst status = await job.status() // 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'\nconst result = await job.waitForCompletion(30_000)\n```\n\n### Queue Adapters\n\n**BullMQ** (Redis-based):\n\n```typescript\nimport { BullMQQueueService } from '@pikku/queue-bullmq'\n\nconst queueService = new BullMQQueueService({\n connection: { host: 'localhost', port: 6379 },\n})\n```\n\n**PgBoss** (PostgreSQL-based):\n\n```typescript\nimport { PgBossQueueService } from '@pikku/queue-pg-boss'\n\nconst queueService = new PgBossQueueService({\n connectionString: 'postgres://...',\n})\n```\n\n## Complete Example\n\n```typescript\n// functions/email.functions.ts\nexport const sendWelcomeEmail = pikkuSessionlessFunc({\n title: 'Send Welcome Email',\n func: async ({ emailService, db }, { userId }, wire) => {\n await wire.queue.updateProgress(10)\n\n const user = await db.getUser(userId)\n if (!user) {\n await wire.queue.discard('User not found')\n return\n }\n\n await wire.queue.updateProgress(50)\n await emailService.send({\n to: user.email,\n subject: 'Welcome!',\n template: 'welcome',\n data: { name: user.name },\n })\n\n await wire.queue.updateProgress(100)\n return { sent: true, email: user.email }\n },\n})\n\n// wirings/queue.wiring.ts\nwireQueueWorker({\n name: 'welcome-emails',\n func: sendWelcomeEmail,\n config: { removeOnComplete: 100 },\n})\n\n// Enqueue from another function\nexport const registerUser = pikkuSessionlessFunc({\n title: 'Register User',\n func: async ({ db, queue }, { email, name }) => {\n const user = await db.createUser({ email, name })\n await queue.add('welcome-emails', { userId: user.id })\n return { user }\n },\n})\n```\n", "pikku-react/SKILL.md": "---\nname: pikku-react\ndescription: 'Set up @pikku/react in a React app: PikkuProvider context, createPikku factory, and the usePikkuRPC / usePikkuFetch hooks for direct (non-React-Query) calls. TRIGGER when: the user is bootstrapping a React frontend that talks to a Pikku backend, asks how to wire `PikkuProvider`, or needs to make one-off RPC calls outside of useQuery/useMutation. DO NOT TRIGGER when: the user is asking about useQuery/useMutation hooks (use pikku-react-query) or about workflows (use pikku-workflows-client).'\ninstallGroups: [core]\n---\n\n# Pikku React\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/react` is the smallest possible binding: a Context provider plus\ntwo hooks. It does **not** depend on React Query — that's a separate\nopt-in via the generated `api.gen.ts`. Use this skill when setting up the\nprovider or making direct RPC calls.\n\n## What ships\n\n```tsx\nimport {\n PikkuProvider,\n createPikku,\n usePikkuFetch,\n usePikkuRPC,\n usePikkuRealtime,\n usePikkuAgent,\n usePikkuWorkflow,\n asI18n,\n} from '@pikku/react'\n```\n\n`usePikkuRealtime` is only valid when you wired a `PikkuRealtime` class via\n`createPikku` — see the setup section. `usePikkuAgent` and `usePikkuWorkflow`\nare thin bindings over the RPC client that pin one agent/workflow name, so a\ncomponent never repeats it. `asI18n` is the i18n brand (see **pikku-i18n**).\n\n## Resolving the server URL\n\nEvery client (`createPikku`, realtime, the auth client) resolves its base\nthrough one shared helper in `src/lib/env.ts`. Write this once:\n\n```ts\n// Endpoints come from env, never hardcoded.\nexport function apiUrl(): string {\n // SSR: the client hooks only run in the browser, so a placeholder is fine.\n if (import.meta.env.SSR) {\n return import.meta.env.VITE_API_URL ?? '/__api'\n }\n return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`\n}\n```\n\n**Never fall back to `http://localhost:3000`.** `import.meta.env.VITE_API_URL`\nis substituted by Vite at *build* time, so any deploy that supplies the URL as\na *runtime* env var or platform binding leaves it `undefined` in the shipped\nbundle — the fallback is then the only branch that ever runs in the browser. A\nlocalhost fallback means every request from a deployed app goes to the user's\nown machine. `origin + '/api'` is same-origin, needs no build-time knowledge of\nthe domain, and is correct wherever the app is served from.\n\nFor local dev, set `VITE_API_URL`, or proxy `/api` → your backend in\n`vite.config.ts` under `server.proxy`. One `/api` entry also covers\n`/api/auth/*`; only add more entries for root-level routes outside `/api`.\n\n## Setup at the app root\n\n```tsx\nimport { createPikku, PikkuProvider } from '@pikku/react'\nimport { PikkuFetch } from './pikku/pikku-fetch.gen'\nimport { PikkuRPC } from './pikku/pikku-rpc.gen'\nimport { apiUrl } from './lib/env'\n\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n serverUrl: apiUrl(),\n})\n\ncreateRoot(document.getElementById('root')!).render(\n <PikkuProvider pikku={pikku}>\n <App />\n </PikkuProvider>\n)\n```\n\nIf the project also exposes realtime events (see **pikku-realtime**), pass\nthe `PikkuRealtime` class as the third argument and the instance gets a\n`realtime` field too:\n\n```tsx\nimport { PikkuRealtime } from './pikku/realtime.gen'\n\nconst pikku = createPikku(PikkuFetch, PikkuRPC, PikkuRealtime, {\n serverUrl: apiUrl(),\n})\n// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch\n// (server URL + auth configured once).\n```\n\nThe generated classes come from your `pikku.config.json`:\n\n| config field | generated file |\n| ---------------------------- | ----------------------------------------------------- |\n| `clientFiles.fetchFile` | typed HTTP client (`PikkuFetch` class) |\n| `clientFiles.rpcWiringsFile` | RPC client (`PikkuRPC` class) calling all exposed fns |\n| `clientFiles.realtimeFile` | `PikkuRealtime` (websocket events + SSE + channels) |\n\nIf a file isn't being generated, that field is missing from the config —\nadd it and re-run `pikku all`.\n\n`createPikku(...)` accepts the same `CorePikkuFetchOptions` as `PikkuFetch`\nplus `serverUrl`. Auth headers, request interceptors, etc. are configured\non the fetch instance — RPC and realtime inherit them automatically.\n\n## Calling an RPC directly (no React Query)\n\nInside a component:\n\n```tsx\nimport { usePikkuRPC } from '@pikku/react'\n\nfunction Logout() {\n const rpc = usePikkuRPC()\n return <button onClick={() => rpc.invoke('logoutUser', {})}>Sign out</button>\n}\n```\n\n`rpc.invoke(name, data)` is typed against `FlattenedRPCMap` — `name` must\nbe an exposed function id, `data` matches the input schema, return value\nmatches the output schema.\n\nYou also have `rpc.<funcName>(data)` if the generated RPC client builds\ndirect methods (project-dependent).\n\n## Calling fetch directly\n\n```tsx\nconst fetch = usePikkuFetch()\nconst data = await fetch.get('/some-rest-route', { searchParams: {...} })\n```\n\nUse this only when the function is wired via HTTP (REST shape) and you\nneed a path-style call. For RPC calls, `usePikkuRPC()` is cleaner.\n\n## Realtime subscriptions\n\nIf you wired a `PikkuRealtime` class into `createPikku`, use\n`usePikkuRealtime()` to grab the shared instance:\n\n```tsx\nimport { usePikkuRealtime } from '@pikku/react'\nimport type { PikkuRealtime } from './pikku/realtime.gen'\n\nfunction TodoList() {\n const realtime = usePikkuRealtime<PikkuRealtime>()\n useEffect(() => {\n return realtime.subscribe('todo-created', ({ todo }) => {\n /* ... */\n })\n }, [realtime])\n // ...\n}\n```\n\nThe hook throws if no `PikkuRealtime` was wired — that's how you know to\nadd it to `createPikku(...)`. Full event-hub setup, publishing, and SSE\nhelpers live in **pikku-realtime**.\n\n## When to reach for what\n\n| Need | Use |\n| ----------------------------------- | --------------------------------------------- |\n| Render data, dedupe + cache | **usePikkuQuery** (react-query) |\n| Trigger a write, wait for result | **usePikkuMutation** (react-query) |\n| Paginate | **usePikkuInfiniteQuery** (react-query) |\n| One-off call from an event handler | `usePikkuRPC()` direct |\n| Hit a REST endpoint (not RPC) | `usePikkuFetch()` |\n| Run one named workflow | `usePikkuWorkflow('name')` → `.start/.run/.status` |\n| Talk to one named AI agent | `usePikkuAgent('name')` → `.run/.stream/.approve` |\n| Longer-running workflow UX | **pikku-workflows-client** |\n| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |\n\nThe first three live in your generated `api.gen.ts` (see the\n**pikku-react-query** skill). This skill covers the rest.\n\n`usePikkuAgent` and `usePikkuWorkflow` bind the name once and hand back the\ncall methods with it already applied:\n\n```tsx\nconst agent = usePikkuAgent('todo-agent')\nconst { text } = await agent.run({ message, threadId })\n\nconst workflow = usePikkuWorkflow('onboardUser')\nconst { runId } = await workflow.start({ email })\nconst state = await workflow.status(runId)\n```\n\n## Authentication\n\nAuth is handled at the `PikkuFetch` layer, and `createPikku`'s options object\n*is* `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a\n`fetchOptions` key:\n\n```tsx\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n serverUrl: apiUrl(),\n credentials: 'include', // cookie sessions\n authHeaders: { jwt: token }, // or { apiKey }\n transformDate: true,\n})\n```\n\nThere is no request-interceptor hook. For a token that changes after startup,\ncall the setter on the shared instance — RPC and realtime pick it up because\nthey hold the same fetch:\n\n```tsx\npikku.fetch.setAuthorizationJWT(token) // null clears it\npikku.fetch.setAPIKey(key)\npikku.fetch.setHeader('x-tenant', tenantId)\n```\n\n`authHeaders.jwt` becomes `Authorization: Bearer …` and `authHeaders.apiKey`\nbecomes `X-API-KEY`; setting a JWT takes precedence over an API key.\n\n## What NOT to do\n\n- Don't instantiate `PikkuFetch`/`PikkuRPC` inside a component — `createPikku`\n goes once at the app root, the instance flows through Context.\n- Don't call `usePikkuRPC()` outside a `<PikkuProvider>` — it throws.\n- Don't write a custom RPC client. The generated one already covers every\n exposed function with full types.\n- Don't hardcode user-facing strings. Every display string goes through an\n i18n token — see **pikku-i18n** for the setup (it's English-only by default).\n", "pikku-react-query/SKILL.md": "---\nname: pikku-react-query\ndescription: 'Use the Pikku auto-generated React Query hooks (`usePikkuQuery`, `usePikkuMutation`, `usePikkuInfiniteQuery`) to call backend RPC functions from a React frontend with full type safety. TRIGGER when: writing React components that need to call a Pikku function, fetch data, mutate data, or paginate; user mentions React Query, useQuery, useMutation, or building a frontend that talks to a Pikku backend. DO NOT TRIGGER when: working on the backend (use pikku-rpc / pikku-feature) or wiring a non-React frontend.'\ninstallGroups: [core]\n---\n\n# Pikku React Query Hooks\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nPikku generates a typed React Query layer from your backend `expose: true`\nfunctions. You don't write `useQuery`/`useMutation` against `fetch`\nyourself — you call hooks named after RPCs and get full type inference for\ninput + output.\n\n## Discover what's available on the client\n\nBefore writing a hook, get the full client surface in one call:\n\n```bash\nyarn pikku meta clients --json\n```\n\nReturns RPCs, workflows, and channels with descriptions and type names:\n\n```json\n{\n \"rpcs\": [\n { \"name\": \"createTodo\", \"description\": \"Create a todo\",\n \"readonly\": false, \"input\": \"CreateTodoInput\", \"output\": \"CreateTodoOutput\" },\n { \"name\": \"listTodos\", \"description\": \"List all todos\",\n \"readonly\": true, \"input\": null, \"output\": \"ListTodosOutput\" }\n ],\n \"workflows\": [...],\n \"channels\": [...]\n}\n```\n\nThe `name` is the RPC identifier; pass it to the hooks below. Input/output\nshapes are inferred automatically — the hook is typed against\n`FlattenedRPCMap[name]['input' | 'output']`. Use `description` to pick the\nright RPC; use `readonly` to choose `usePikkuQuery` vs `usePikkuMutation`.\n\n## Setup (once per app)\n\nIn your app entry (e.g. `main.tsx`):\n\n```tsx\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { PikkuProvider, createPikku } from '@pikku/react'\nimport { PikkuFetch } from './pikku/pikku-fetch.gen'\nimport { PikkuRPC } from './pikku/pikku-rpc.gen'\n\nimport { apiUrl } from './lib/env'\n\nconst queryClient = new QueryClient()\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n serverUrl: apiUrl(),\n})\n\n<QueryClientProvider client={queryClient}>\n <PikkuProvider pikku={pikku}>\n <App />\n </PikkuProvider>\n</QueryClientProvider>\n```\n\nThe two generated files come from `pikku.config.json`'s\n`clientFiles.fetchFile` and `clientFiles.rpcWiringsFile`. Hooks live in\nthe file at `clientFiles.reactQueryFile` (typically `api.gen.ts`).\n\n`apiUrl()` is the shared server-URL helper — see **pikku-react**. Never\ninline `?? 'http://localhost:3000'`: a deploy that supplies the URL as a\nruntime binding leaves `import.meta.env.VITE_API_URL` undefined in the\nbundle, so the fallback is the branch that actually runs.\n\n## TanStack Start (SSR)\n\nUnder Start the provider mounts in `routes/__root.tsx` rather than\n`main.tsx`, and the same module is evaluated on the server. Three things\ndiffer:\n\n1. **`apiUrl()` must have an SSR branch.** `window` is undefined during\n render; return the build-time var or a placeholder (the client hooks\n only fire in the browser).\n2. **Build auth clients lazily.** Better Auth validates its baseURL with\n `new URL(...)` at construction, so a module-scope `createAuthClient`\n crashes SSR on the placeholder. Memoize it behind a getter:\n\n ```ts\n let _authClient: ReturnType<typeof createAuthClient> | undefined\n export const authClient = () =>\n (_authClient ??= createAuthClient({ baseURL: `${apiUrl()}/auth` }))\n ```\n\n3. **The auth baseURL needs the `/auth` suffix.** Better Auth only\n appends its default `/api/auth` when the baseURL carries no path.\n `apiUrl()` already ends in `/api`, so a bare `apiUrl()` leaves the\n client calling `/api/get-session` and 404ing.\n\nServer functions that need typed RPC access use the generated shim:\n\n```bash\npikku tanstack-start # emits the makeApi server-function shim\n```\n\n## The hooks\n\nAll hooks are imported from your generated `api.gen.ts`:\n\n```tsx\nimport {\n usePikkuQuery,\n usePikkuMutation,\n usePikkuInfiniteQuery,\n} from './pikku/api.gen'\n```\n\n### `usePikkuQuery(name, data, options?)`\n\nFor RPCs that **read** data. Cacheable. The hook is typed against the RPC's\ninput + output.\n\n```tsx\nexport function TodoList() {\n const { data, isLoading, error } = usePikkuQuery('listTodos', {})\n\n if (isLoading) return <p>Loading…</p>\n if (error) return <p>{error.message}</p>\n return (\n <ul>\n {data?.todos.map((t) => (\n <li key={t.id}>{t.title}</li>\n ))}\n </ul>\n )\n}\n```\n\nThe query key is `[name, data]` automatically — no manual key wrangling.\nPass standard `useQuery` options through (`staleTime`, `enabled`, etc.).\n\n### `usePikkuMutation(name, options?)`\n\nFor RPCs that **write**. Returns a React Query mutation object.\n\n```tsx\nexport function CreateTodoForm() {\n const queryClient = useQueryClient()\n const mutation = usePikkuMutation('createTodo', {\n onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listTodos'] }),\n })\n\n const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n e.preventDefault()\n const title = (\n e.currentTarget.elements.namedItem('title') as HTMLInputElement\n ).value\n mutation.mutate({ title })\n }\n\n return (\n <form onSubmit={onSubmit}>\n <input name=\"title\" />\n <button type=\"submit\" disabled={mutation.isPending}>\n {mutation.isPending ? 'Adding…' : 'Add'}\n </button>\n </form>\n )\n}\n```\n\nThe input passed to `mutation.mutate(...)` is type-checked against the RPC's\ninput schema. After success, **invalidate** any list/get queries that should\nrefetch.\n\n### `usePikkuInfiniteQuery(name, data, options?)`\n\nThe hook's `name` parameter is narrowed to RPCs whose **output** has a\n`nextCursor?: string | null` field, so calling it with anything else is a type\nerror rather than a missing hook. That output field is read after each page and\nsent back as the **input** field `cursor` — which is why the `data` you pass is\n`Omit<input, 'cursor'>`: the hook owns that key.\n\n```tsx\nconst { data, fetchNextPage, hasNextPage, isFetchingNextPage } =\n usePikkuInfiniteQuery('listTodos', { limit: 20 })\n\nconst todos = data?.pages.flatMap((p) => p.todos) ?? []\n```\n\nSo the backend contract is a pair: output `nextCursor`, input `cursor`. An RPC\nmissing either one paginates with `usePikkuQuery` and manual cursor state\ninstead.\n\n## Workflow hooks\n\nWhen the project defines any workflow, the same file also gains\n`useStartWorkflow(name)` (mutation → `{ runId }`), `useRunWorkflow(name)`\n(mutation → the workflow's output) and `useWorkflowStatus(name, runId?)` (query,\ndisabled until `runId` is set). See the **pikku-workflows-client** skill.\n\n## Calling RPCs without React Query\n\nFor one-off calls (event handlers outside of state, side effects), use\n`usePikkuRPC()` from `@pikku/react`:\n\n```tsx\nconst rpc = usePikkuRPC()\nconst handleClick = async () => {\n const result = await rpc.invoke('createTodo', { title: 'inline' })\n}\n```\n\nBut prefer the React Query hooks for anything that touches render state —\ncaching, retries, dedup, and dev-tools come for free.\n\n## Common patterns\n\n- **Optimistic updates**: pass `onMutate` to `usePikkuMutation` to update\n the cache before the server responds. Standard React Query pattern;\n Pikku doesn't add anything special.\n- **Conditional fetching**: pass `enabled: !!someValue` to skip a query\n until you have the input.\n- **Refetch on focus**: enabled by default in React Query; disable with\n `refetchOnWindowFocus: false` in options.\n\n## What NOT to do\n\n- Don't import the RPC client directly and call it inside `useEffect` —\n use the hooks. They handle dedup, caching, and unmount safely.\n- Don't hand-write `useQuery({ queryKey: ['listTodos'], queryFn: ... })`\n — `usePikkuQuery('listTodos', {})` does it correctly with one line.\n- Don't construct hook names dynamically. Hook names = RPC names known at\n generation time.\n- Don't bypass the type system with `as any` — if a hook's types don't\n match what you expect, the backend's input/output schemas are wrong;\n fix those first.\n", "pikku-realtime/references/other-routes.md": "# Subscribing to other SSE / WebSocket routes\n\nThe same `PikkuRealtime` client also handles generic SSE + channel routes (not just\n`/events` topics). Use the path; the base URL is inherited from `PikkuFetch`.\n\n```ts\n// Any `sse: true` HTTP route\nconst sub = realtime.subscribeToSSE<{ progress: number }>(\n `/workflow-run/${runId}/stream`,\n (event) => setProgress(event.progress)\n)\n// later: sub.close()\n\n// Any wireChannel — open a raw socket, wrap in PikkuWebSocket for typed I/O\nconst ws = realtime.connectToChannel('/ws/kanban')\nconst typed = new PikkuWebSocket<'kanban-live'>(ws)\ntyped.getRoute('command').subscribe('message', (data) => {\n /* ... */\n})\n```\n\nDiscover what's available with `pikku meta clients --json` — `channels` and any HTTP\n`sse: true` routes are listed there.\n", "pikku-realtime/SKILL.md": "---\nname: pikku-realtime\ndescription: 'Use Pikku''s realtime feature — typed pub/sub events over WebSocket (multi-topic) or SSE (single-topic, auto-cleanup). Covers declaring EventHubTopics, scaffolding the /events channel, the auto-generated `PikkuRealtime` client, and publishing events from a function. TRIGGER when: the user asks for realtime updates, pub/sub, push notifications, server-sent events, websocket events, eventhub, or \"live\" data on the frontend. DO NOT TRIGGER when: the user wants RPC-style request/response (use pikku-rpc / pikku-react-query) or a custom one-off WebSocket channel (use pikku-websocket).'\ninstallGroups: [core]\n---\n\n# Pikku Realtime\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nMost realtime UI is just typed pub/sub: a server pushes `todo-created`, the client\nrenders it. Pikku ships exactly that, two ways — both use the same `EventHubService`\nand the same publish call, so choose by transport, not by code shape:\n\n- **WebSocket** at `/events` — one connection, many topic subscriptions.\n- **SSE** at `GET /events/:topic` — one connection per topic, auto-cleanup on\n disconnect. Good when WebSocket is blocked or for trivially streaming one topic.\n\n## 1. Declare your topics\n\nIn your project's types file (e.g. `types/eventhub-topics.d.ts`):\n\n```ts\nimport type { Todo } from '../src/schemas.js'\n\nexport type EventHubTopics = {\n 'todo-created': { todo: Todo }\n 'todo-updated': { todo: Todo }\n 'todo-deleted': { todoId: string }\n}\n```\n\nReference it in `application-types.d.ts` and instantiate it in `services.ts`:\n\n```ts\n// application-types.d.ts\nimport type { EventHubService } from '@pikku/core/channel'\nimport type { EventHubTopics } from './eventhub-topics.js'\n\nexport interface SingletonServices extends CoreSingletonServices<Config> {\n // `CoreSingletonServices` declares eventHub optional; re-declare it required\n // so functions can use it without a `if (eventHub)` guard on every publish.\n eventHub: EventHubService<EventHubTopics>\n}\n\n// services.ts\nimport { LocalEventHubService } from '@pikku/core/channel'\nconst eventHub = new LocalEventHubService<EventHubTopics>()\n```\n\nFor multi-instance deployments use `CloudflareEventHubService` /\n`LambdaEventHubService` / `UWSEventHubService` instead — same interface.\n\nIf a deployment genuinely has no eventHub, that belongs in `services.ts` (don't\ncreate the service there), not as an optional type every function has to guard —\nsee `pikku-services`.\n\n## 2. Enable the server side\n\n```bash\nyarn pikku enable events # auth required by default\nyarn pikku enable events --noAuth # public events\n```\n\nThis sets `scaffold.events` in `pikku.config.json`. The next `pikku all` generates\n`events.gen.ts` in your scaffold dir, wiring (using whatever `eventHub` is in your\nsingletons — you write neither by hand):\n\n- A WebSocket channel at `/events` handling `{action: 'subscribe' | 'unsubscribe', topic}` messages.\n- An SSE handler at `GET /events/:topic`.\n\n## 3. Generate the typed client\n\nAdd to `pikku.config.json`:\n\n```jsonc\n{\n \"clientFiles\": {\n \"realtimeFile\": \"packages/sdk/src/pikku/realtime.gen.ts\",\n // Optional: full type inference for subscribe/unsubscribe\n \"realtimeEventHubTopicsImport\": \"../../../functions/types/eventhub-topics.js#EventHubTopics\",\n },\n}\n```\n\nRun `pikku all` (or `pikku realtime` to regenerate just this file). Everything is\non one class — both transports are methods, so switching from WebSocket to SSE is\na one-word change, not a different import:\n\n```ts\nexport class PikkuRealtime {\n constructor(options?: { reconnect?: boolean; reconnectDelayMs?: number; reconnectMaxDelayMs?: number })\n setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor\n\n // WebSocket at /events — many topics on one connection\n subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void\n unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: (data: EventHubTopics[K]) => void): void\n\n // SSE at GET /events/:topic — one EventSource per topic\n subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }\n\n // generic escape hatches — see references/other-routes.md\n subscribeToSSE<T>(path: string, handler: (data: T) => void): { close: () => void }\n connectToChannel(channelRoute: string, protocols?: string | string[]): WebSocket\n\n close(): void\n}\n```\n\nWithout `realtimeEventHubTopicsImport`, the client falls back to\n`Record<string, unknown>` — usable but untyped. Set the import for full typed\nsubscribe/unsubscribe.\n\n## 4. Publish events from a function\n\nThe `/events` channel listens for client subscriptions; the eventHub fans out\npublishes:\n\n```ts\npublish(topic, channelId: string | null, data, isBinary?)\n```\n\nThe middle argument is the channel to **skip**, not the one to send to — pass\n`null` to reach every subscriber, or the current `channel.channelId` when the\noriginating connection has already applied the change locally and would otherwise\nrender it twice.\n\nEnvelope the payload as `{ topic, data }`: the generated client dispatches on the\n`topic` field, so a bare payload arrives but no handler fires.\n\n```ts\nimport { pikkuFunc } from '#pikku'\n\nexport const createTodo = pikkuFunc({\n input: CreateTodoInput,\n output: CreateTodoOutput,\n func: async ({ kysely, eventHub }, data) => {\n const todo = await kysely\n .insertInto('todos').values(data).returningAll()\n .executeTakeFirstOrThrow()\n\n await eventHub.publish('todo-created', null, {\n topic: 'todo-created',\n data: { todo },\n })\n return { id: todo.id }\n },\n})\n```\n\nA thin helper removes the duplication:\n\n```ts\nasync function publishEvent<K extends keyof EventHubTopics>(\n hub: EventHubService<EventHubTopics>, topic: K, data: EventHubTopics[K]\n) {\n return hub.publish(topic, null, { topic, data })\n}\n// usage: await publishEvent(eventHub, 'todo-created', { todo })\n```\n\n## 5. Wire it up — share fetch with PikkuRPC\n\n`PikkuRealtime` mirrors `PikkuRPC`: it wraps the same `PikkuFetch`, so server URL +\nauth are configured **once** and shared across HTTP, RPC, and realtime transports.\n\n```tsx\nimport { createPikku, PikkuProvider } from '@pikku/react'\nimport { PikkuFetch } from './pikku/pikku-fetch.gen'\nimport { PikkuRPC } from './pikku/pikku-rpc.gen'\nimport { PikkuRealtime } from './pikku/realtime.gen'\n\nconst pikku = createPikku(\n PikkuFetch,\n PikkuRPC,\n PikkuRealtime, // pass the realtime class as the third arg\n { serverUrl: apiUrl() } // shared env helper — see pikku-react\n)\n// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.\n\ncreateRoot(document.getElementById('root')!).render(\n <PikkuProvider pikku={pikku}><App /></PikkuProvider>\n)\n```\n\nOr wire manually:\n\n```ts\nconst realtime = new PikkuRealtime()\nrealtime.setPikkuFetch(pikku.fetch) // inherits serverUrl + auth\n```\n\n## 6. Subscribe from React\n\nSubscribe inside `useEffect` (never the render path, or you create a subscription\nper render). `subscribe` returns an unsubscribe function; SSE's `subscribeToTopic`\nreturns a handle with `close()`:\n\n```tsx\nimport { useEffect, useState } from 'react'\n\nfunction TodoList() {\n const { realtime } = usePikku() // a hook over your context\n const [todos, setTodos] = useState<Todo[]>([])\n\n useEffect(() => {\n // WebSocket multi-topic:\n const off = realtime.subscribe('todo-created', ({ todo }) =>\n setTodos((prev) => [...prev, todo]))\n return off\n\n // Single-topic SSE (auto-cleanup on close) instead:\n // const sub = realtime.subscribeToTopic('todo-created', ({ todo }) =>\n // setTodos((prev) => [...prev, todo]))\n // return () => sub.close()\n }, [realtime])\n\n return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>\n}\n```\n\n## Other SSE / WebSocket routes\n\nThe same client also subscribes to generic `sse: true` routes and raw `wireChannel`\nsockets (`subscribeToSSE`, `connectToChannel`). See\n[references/other-routes.md](references/other-routes.md).\n\n## When to pick which transport\n\n| Need | Use |\n| ------------------------------------------ | ----------------------------- |\n| Many topics in one connection | `realtime.subscribe` |\n| Single live stream, simple cleanup | `realtime.subscribeToTopic` |\n| Bidirectional (client also sends messages) | `realtime.subscribe` |\n| WebSockets blocked by infra | `realtime.subscribeToTopic` |\n\nBoth auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes\nall topics for the dead channel id). Don't write manual cleanup unless you're\nunsubscribing partway through a session.\n\n## What NOT to do\n\n- Don't call `eventHub.publish(topic, ..., rawData)` without the `{topic, data}`\n envelope — clients use `topic` to dispatch handlers.\n- Don't create your own `/events` channel by hand — `pikku enable events` already\n does it correctly with disconnect cleanup.\n- Don't subscribe inside the render path — use `useEffect`.\n- Don't subscribe to topics that don't exist in `EventHubTopics`. The generated\n client's types prevent it; if you reach for `as any` to subscribe to a string,\n declare the topic first.\n", "pikku-redis/SKILL.md": "---\nname: pikku-redis\ndescription: >-\n Use when setting up Redis-backed services in a Pikku app. Covers channel stores, workflow\n services, secret services, event hubs, agent runs, and deployment services backed by Redis.\n TRIGGER when: code uses RedisChannelStore, RedisWorkflowService, RedisSecretService, or user\n asks about Redis setup with Pikku. DO NOT TRIGGER when: user asks about BullMQ queues (use\n pikku-queue) or SQL databases (use pikku-kysely).\n---\n\n# Pikku Redis\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/redis` provides Redis-backed implementations of Pikku's core service interfaces using [ioredis](https://github.com/redis/ioredis).\n\n## Installation\n\n```bash\nyarn add @pikku/redis\n```\n\n## API Reference\n\n### Available Services\n\nAll services accept a Redis connection (ioredis `Redis` instance, `RedisOptions`, or connection string) in their constructor.\n\n| Service | Interface | Purpose |\n| ------------------------- | ---------------------- | ---------------------------------------------- |\n| `RedisChannelStore` | `ChannelStore` | WebSocket channel state persistence |\n| `RedisEventHubStore` | `EventHubStore` | Event hub state persistence |\n| `RedisWorkflowService` | `PikkuWorkflowService` | Workflow definition storage |\n| `RedisWorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |\n| `RedisDeploymentService` | `DeploymentService` | Deployment state management |\n| `RedisAgentRunService` | `AgentRunService` | Agent execution tracking |\n| `RedisSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |\n| `RedisSessionStore` | `SessionStore` | Persisted user sessions |\n\n### Secret Service\n\nEnvelope encryption: `key` derives the KEK that wraps each secret's own DEK.\nKeeping `previousKey` set is what makes `rotateKEK()` possible — it re-wraps\nevery secret onto the current key and returns the new version.\n\n```typescript\nimport { RedisSecretService } from '@pikku/redis'\n\nconst secrets = new RedisSecretService(\n connectionOrConfig: Redis | RedisOptions | string,\n config: {\n key: string // the KEK passphrase\n keyVersion?: number // defaults to 1\n previousKey?: string // required to rotate\n keyPrefix?: string // namespaces the redis keys\n }\n)\n\nawait secrets.getSecret<T = string>(key: string): Promise<T>\nawait secrets.getSecrets<T>(keys: (keyof T & string)[]): Promise<Partial<T>>\nawait secrets.hasSecret(key: string): Promise<boolean>\nawait secrets.setSecret(key: string, value: unknown): Promise<void>\nawait secrets.deleteSecret(key: string): Promise<void>\nawait secrets.rotateKEK(): Promise<number>\nawait secrets.close(): Promise<void>\n```\n\n## Usage Patterns\n\n### Full Setup\n\n```typescript\nimport {\n RedisChannelStore,\n RedisWorkflowService,\n RedisSecretService,\n} from '@pikku/redis'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n\n const channelStore = new RedisChannelStore(config.redisUrl)\n const workflowService = new RedisWorkflowService(config.redisUrl)\n\n const secrets = new RedisSecretService(config.redisUrl, {\n key: config.kekPassphrase,\n })\n\n return { config, logger, channelStore, workflowService, secrets }\n})\n```\n", "pikku-rpc/SKILL.md": "---\nname: pikku-rpc\ndescription: >-\n Use when making internal function-to-function calls within a Pikku app, composing functions, or\n exposing RPC endpoints. Covers rpc.invoke, rpc.remote, rpc.exposed, and generated RPC client.\n TRIGGER when: code uses wire.rpc or expose: true, user asks about calling one Pikku function\n from another, function composition, or RPC endpoints. DO NOT TRIGGER when: user asks about HTTP\n routes (use pikku-http) or addon cross-package calls (use pikku-addon).\ninstallGroups: [core]\n---\n\n# Pikku RPC Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nCall Pikku functions from other Pikku functions internally with full type safety. Use RPC to compose business logic without importing functions directly.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and which could be called via RPC\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### RPC Methods (on `wire.rpc`)\n\n| Method | Purpose |\n| -------------------------------- | ----------------------------------------- |\n| `rpc.invoke(name, data)` | Internal call to any wired function |\n| `rpc.remote(name, data)` | Remote call via DeploymentService |\n| `rpc.exposed(name, data)` | Call functions marked with `expose: true` |\n| `rpc.startWorkflow(name, input)` | Start a workflow (see `pikku-workflow`) |\n| `rpc.agent.run/stream(...)` | Run an AI agent (see `pikku-ai-agent`) |\n| `rpc.agent.resume/approve(...)` | Answer a tool-approval interrupt |\n| `rpc.agent.interrupt(runId)` | Stop an in-flight run |\n\n`rpc.invoke`, `rpc.remote` and `rpc.startWorkflow` are typed off the generated\nRPC map, so the name and the payload are checked. `rpc.exposed` is deliberately\n`(name: string, data: any) => Promise<any>` — it exists to dispatch a name that\narrived from outside, which by definition cannot be checked at compile time.\nReach for `rpc.invoke` whenever the name is known statically.\n\n`rpc` also carries `depth` (how deep the current RPC chain is, so runaway\nrecursion is visible) and `global`.\n\n### Exposed Functions\n\nMark a function as externally callable via RPC:\n\n```typescript\nconst greet = pikkuSessionlessFunc({\n title: 'Greet',\n expose: true, // ← callable via rpc.exposed()\n func: async ({}, { name }) => {\n return { message: `Hello, ${name}!` }\n },\n})\n```\n\n### HTTP RPC Endpoint\n\nThe `POST /rpc/:rpcName` endpoint that dispatches every `expose: true` function\nis **generated, not hand-written**. Turn it on and let codegen own it:\n\n```bash\npikku enable rpc # sets scaffold.rpc = true (auth required)\npikku enable rpc --noAuth # sets scaffold.rpc = { auth: false } (public)\n```\n\nThis writes `rpc-public.gen.ts` with an `rpcCaller` function and its `wireHTTP`\ncall already wired. Do not write that wiring yourself — a hand-rolled copy\ncollides with the generated route on the same path.\n\n## Usage Patterns\n\n### Internal Function Composition\n\n```typescript\nconst calculateTax = pikkuSessionlessFunc({\n title: 'Calculate Tax',\n func: async ({}, { amount, rate }) => {\n return { tax: amount * rate }\n },\n})\n\nconst processOrder = pikkuFunc({\n title: 'Process Order',\n func: async ({ db }, { orderId }, { rpc }) => {\n const order = await db.getOrder(orderId)\n\n // Call another pikku function internally — fully typed\n const { tax } = await rpc.invoke('calculateTax', {\n amount: order.total,\n rate: 0.08,\n })\n\n return { orderId, total: order.total + tax }\n },\n})\n```\n\n### When to Use RPC vs Direct Imports\n\n| Approach | Use When |\n| -------------- | -------------------------------------------------------------------------------------------- |\n| `rpc.invoke()` | Cross-domain calls, maintaining separation of concerns, function may be in different package |\n| Direct import | Same module, tightly coupled logic, performance critical |\n\nRPC calls go through Pikku's middleware and permission pipeline. Direct imports skip them.\n\n### Generated RPC Client\n\nAfter `npx pikku all`:\n\n```typescript\nimport { pikkuRPC } from '#pikku/pikku-rpc.gen.js'\n\npikkuRPC.setServerUrl('http://localhost:4002')\n\nconst result = await pikkuRPC.invoke('calculateTax', {\n amount: 100,\n rate: 0.08,\n})\n\npikkuRPC.setAuthorizationJWT(token)\n```\n\n## Complete Example\n\n```typescript\n// functions/billing.functions.ts\nexport const calculateTax = pikkuSessionlessFunc({\n title: 'Calculate Tax',\n func: async ({}, { amount, region }) => {\n const rates = { US: 0.08, EU: 0.2, UK: 0.2 }\n return { tax: amount * (rates[region] || 0) }\n },\n})\n\nexport const calculateShipping = pikkuSessionlessFunc({\n title: 'Calculate Shipping',\n func: async ({}, { weight, region }) => {\n const base = region === 'US' ? 5 : 15\n return { shipping: base + weight * 0.5 }\n },\n})\n\n// functions/orders.functions.ts\nexport const processOrder = pikkuFunc({\n title: 'Process Order',\n func: async ({ db }, { orderId }, { rpc }) => {\n const order = await db.getOrder(orderId)\n\n const { tax } = await rpc.invoke('calculateTax', {\n amount: order.total,\n region: order.region,\n })\n\n const { shipping } = await rpc.invoke('calculateShipping', {\n weight: order.totalWeight,\n region: order.region,\n })\n\n const finalTotal = order.total + tax + shipping\n await db.updateOrder(orderId, { tax, shipping, finalTotal })\n\n return { orderId, total: finalTotal, tax, shipping }\n },\n})\n```\n", "pikku-rtl/SKILL.md": "---\nname: pikku-rtl\ndescription: 'Make a Pikku frontend work in both English (LTR) and Arabic / right-to-left languages. Direction is derived from the active locale, applied once at the document root, and the layout mirrors itself — but only if styling is written flow-relative (margin-inline-start, text-align: start, Mantine ms/me) instead of left/right. TRIGGER when: adding Arabic (or Hebrew/Farsi/Urdu), asked to \"support RTL / right-to-left / bidi / mirror the layout\", or writing layout styles in an app that may run RTL. Builds on pikku-i18n (an RTL language is just another locale file). DO NOT TRIGGER for backend functions or for LTR-only copy changes.'\ninstallGroups: [core]\n---\n\n# Pikku RTL (Arabic + English)\n\nThis skill sits **on top of** `pikku-i18n`. That skill compiles a locale's\nmessages into typed `m.*()` functions; this one adds the second axis: a locale\nalso has a **direction**. Arabic is not special-cased — it is just another\n`messages/ar.json` listed in `project.inlang/settings.json`, plus the document\nbeing told it is `rtl`.\n\n## The one idea\n\nSet `dir` **once at the document root** from the active locale, then let the\nbrowser and Mantine mirror everything — _provided_ every custom style is written\n**flow-relative** (start/end), never **physical** (left/right). Get those two\nthings right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component\n*layout* code — directional icons still need one manual step, covered below.\n\n## Agent Operating Procedure\n\n1. **Messages first.** Every visible string is already an `m.*()` message via\n `pikku-i18n`. Arabic copy goes in `messages/ar.json`, mirroring `en.json`'s\n keys with the `{param}` names kept identical.\n2. **Add the direction helper** to the i18n config (one home for locale→dir):\n ```ts\n const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])\n export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {\n return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'\n }\n ```\n (The bundled templates already ship this helper — use it, don't reinvent it.)\n3. **Apply `dir` + `lang` at the root**, once, from the active locale — pick the\n recipe for your framework below.\n4. **Write every layout style flow-relative.** This is the part that actually\n makes mirroring work; see the rules. When editing existing UI to be\n RTL-ready, the job is mostly a search-and-replace of physical properties.\n5. **Flip directional icons** (chevrons, back/forward arrows) — the one thing\n logical properties can't do for you.\n6. Validate with the app's `tsc`, then load `?i18n-debug` / set `dir` and\n eyeball that the layout mirrors and nothing is stuck on the wrong edge.\n\n## Flow-relative, not physical — the rules that make it mirror\n\nUse the **inline-axis logical** property; never the physical one:\n\n| Don't (physical) | Do (flow-relative) |\n| ---------------------------- | -------------------------------------------- |\n| `margin-left` / `marginLeft` | `margin-inline-start` / `marginInlineStart` |\n| `margin-right` | `margin-inline-end` / `marginInlineEnd` |\n| `padding-left/right` | `padding-inline-start/end` |\n| `left: 0` / `right: 0` | `inset-inline-start: 0` / `inset-inline-end` |\n| `text-align: left/right` | `text-align: start / end` |\n| `border-top-left-radius` | `border-start-start-radius` |\n| `float: left/right` | `float: inline-start / inline-end` |\n\nIn **Mantine**, use the logical style props — they emit the logical CSS above:\n\n| Don't | Do |\n| ----------- | ----------- |\n| `ml` / `mr` | `ms` / `me` |\n| `pl` / `pr` | `ps` / `pe` |\n\nMantine's own components already use logical properties internally, so once the\ndirection is set they mirror automatically — you only have to be disciplined in\n**your** styles.\n\n**Leave flexbox and grid alone.** `display:flex` already follows `dir`:\n`justify-content: flex-start` resolves to the right edge under RTL on its own.\nNever \"fix\" RTL by swapping to `flex-direction: row-reverse` or reordering DOM —\nthat double-flips and breaks the moment direction changes. The DOM order is\nlogical order; let `dir` handle the visual order.\n\n## Applying direction at the root\n\n### Mantine app (e.g. environment-template)\n\nMantine ships first-class RTL: wrap the tree in `DirectionProvider` and set the\nmatching `dir` on `<html>`.\n\n```tsx\nimport { DirectionProvider, MantineProvider } from '@mantine/core'\nimport { detectLocale, localeDir } from './i18n/config'\n\nconst locale =\n typeof window !== 'undefined' ? detectLocale(window.location.pathname) : 'en'\nconst dir = localeDir(locale)\n\nif (typeof document !== 'undefined') {\n document.documentElement.lang = locale\n document.documentElement.dir = dir // Mantine + browser read this\n}\n\nroot.render(\n <DirectionProvider initialDirection={dir}>\n <MantineProvider theme={theme} defaultColorScheme=\"dark\">\n {/* …app… */}\n </MantineProvider>\n </DirectionProvider>\n)\n```\n\nTo flip direction live (a language switcher) call\n`document.documentElement.setAttribute('dir', localeDir(next))` and Mantine's\n`useDirection().setDirection(dir)`; both read the same value.\n\n### Plain Vite SPA (kanban, test-harness vite-spa)\n\nNo Mantine — just put `dir`/`lang` on `<html>` at bootstrap, after the locale is\ndetected (the same `detectLocale` the i18n config uses):\n\n```ts\nimport { detectLocale, localeDir } from './i18n/config'\n\nconst locale = detectLocale(window.location.pathname)\ndocument.documentElement.lang = locale\ndocument.documentElement.dir = localeDir(locale)\n```\n\nEverything below inherits `dir` from `<html>`; logical CSS does the mirroring.\n\n### Vite SSR (test-harness vite-ssr)\n\nThe worker renders the full HTML, so set `lang`/`dir` on the server `<html>`\nfrom the **URL** locale (the client inherits it on hydration — no flash):\n\n```tsx\nimport { detectLocale, localeDir } from './i18n/config'\n\nconst locale = detectLocale(new URL(request.url).pathname)\nconst dir = localeDir(locale)\nconst html = `<!doctype html>\n<html lang=\"${locale}\" dir=\"${dir}\">\n …\n</html>`\n```\n\nParaglide's active locale must match: set it (via the i18n config's\n`setActiveLocale` / `overwriteGetLocale` bridge) before `renderToString`, so the\nSSR'd text and `dir` agree.\n\n### Next.js app-router (test-harness next-ssr / next-static)\n\nSet it on the `<html>` in `app/layout.tsx`. With locale-prefixed routes the\nsegment gives the locale; for a single-locale build it's a constant:\n\n```tsx\nimport { localeDir, defaultLocale } from './i18n/config'\n\nexport default function RootLayout({\n children,\n}: {\n children: React.ReactNode\n}) {\n const locale = defaultLocale // or the [lang] route segment / params\n return (\n <html lang={locale} dir={localeDir(locale)}>\n <body>{children}</body>\n </html>\n )\n}\n```\n\nFor `output: 'export'` with `/ar` prefixes, derive `locale` from the route\nsegment so each statically-exported tree carries the right `dir`.\n\n## Directional icons — the manual bit\n\nLogical properties mirror box layout, **not glyphs**. An icon that points\nsomewhere (chevron, back/next arrow, send, undo) must flip under RTL; a\nnon-directional icon (search, settings, avatar) must **not**. Flip with the\n`:dir()` selector — no JS, no per-locale branching:\n\n```css\n:dir(rtl) .icon-directional {\n transform: scaleX(-1);\n}\n```\n\nOr in CSS-in-JS / inline, gate on the resolved direction:\n`transform: localeDir(locale) === 'rtl' ? 'scaleX(-1)' : undefined`.\nPrefer logical icon components if your icon set ships them.\n\n## Arabic typography niceties\n\n- **Font:** the default Latin stack renders Arabic with the system fallback,\n which is inconsistent. Add an Arabic-capable family (e.g. _Noto Sans Arabic_,\n _IBM Plex Sans Arabic_) to `font-family` so both scripts look intentional.\n- **Numerals:** don't hardcode digits. Format numbers/dates with\n `Intl.NumberFormat`/`Intl.DateTimeFormat` given the active locale, so Western\n vs Arabic-Indic digits follow the locale choice.\n- **Line height:** Arabic diacritics sit tall — a slightly larger `line-height`\n on Arabic body text avoids clipping. Keep it locale-scoped, not global.\n\n## Adding Arabic to an existing app — checklist\n\n1. `messages/ar.json` mirroring `en.json`; add `\"ar\"` to `locales` in\n `project.inlang/settings.json` and recompile. Keys missing from `ar.json`\n fall back to the base locale per message rather than failing the build, so\n diff the two files rather than trusting `tsc` to catch a gap here.\n2. Confirm the `localeDir` helper includes `ar` (it does by default).\n3. Confirm the root sets `dir` from the locale (recipe above).\n4. Sweep the app's styles: replace every `left/right`, `ml/mr`, `text-align:\nleft` with the flow-relative equivalent; revert any manual `row-reverse`.\n5. Flip directional icons.\n6. `tsc`, then load the Arabic route and verify the whole layout mirrors —\n sidebar on the right, text right-aligned, arrows pointing the other way.\n\n## What NOT to do\n\n- Don't use physical `left`/`right` (or `ml`/`mr`) in any new layout style — even\n in an English-only app. Writing logical from the start is the seam Arabic\n slots into, exactly like tokens are for copy.\n- Don't fake RTL with `flex-direction: row-reverse`, reversed DOM order, or\n per-locale `if (rtl)` layout branches. Set `dir` once; let layout follow.\n- Don't set `dir` on individual components — it belongs on `<html>` so the whole\n document (and Mantine) agrees.\n- Don't translate Arabic copy outside the message system; an RTL language is a\n normal locale, governed by `pikku-i18n`. There is no `t()` and no i18next in a\n Pikku frontend — the string comes from `m.some__key()`.\n", "pikku-scenario/SKILL.md": "---\nname: pikku-scenario\ndescription: >-\n Use when writing or running Pikku scenarios, or when asked to test Pikku functions or improve\n test coverage. A scenario (pikkuScenario) drives the app the way users do — steps run as actors\n over the real transport against a running server — so a flow doubles as an e2e test and a\n staged/production health check. Covers scenario.do / expectEventually / expectError /\n expectService / expectScore, declared steps via pikkuScenarioStep (including browser steps driven by\n @pikku/playwright) written as intent rather than as clicks, with the actions factored into\n shared browser utilities, actors and environments in pikku.config.json, SCENARIO_ACTOR_SECRET, the\n `pikku scenario list|run` commands, live function coverage via `pikku dev --coverage`, and\n plain unit tests for pure function logic. TRIGGER when: user asks about scenarios, testing a\n Pikku function, test coverage, end-to-end flows, browser/UI e2e, or health checks. DO NOT\n TRIGGER when: user asks about running an existing test suite (use Bash) or CI configuration.\ninstallGroups: [core]\n---\n\n# Pikku Scenarios\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing: `pikku scenario list` for what exists, `pikku info functions --verbose` for what a scenario can call.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, or build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated.\n4. Validate with the narrowest relevant command first, then `pikku all --tsc` when functions, wirings or schemas may have changed.\n5. If validation fails, fix the source cause and rerun. Do not paper over generated errors by editing generated files.\n\n**`pikku tests` does not exist.** It was removed in #865 — scenarios own coverage now. Any reference you find to it is stale.\n\n## What a scenario is\n\nA scenario is a `pikkuScenario` export that drives the app **as real actors over the real transport**, against a running server. That is what lets one artifact serve as both an e2e test and a staged/production health check.\n\nConsequences that matter, and bite if ignored:\n\n- **There is no state reset.** A scenario runs against a live server. Scope what you create (unique ids, your own rows) and never assume a clean database.\n- **Every effect runs as somebody, or as a declared step.** `scenario.do(...)` without `{ actor }` throws `Scenario tried to run '<rpc>' as an internal step…` — there is no bare internal-RPC step. The other way to do work is `scenario.given/when/then`, which runs a `pikkuScenarioStep`; its actor is optional (setup steps have none) unless it declares `browser: true`.\n- **Actors must be configured and signed in**, or the scenario cannot run.\n\nScenarios live in `srcDirectories` like any other function — by convention `*.scenario.ts`.\n\n## Writing one\n\n`pikkuScenario` comes from the **generated** workflow types, not `@pikku/core`:\n\n```typescript\nimport { pikkuScenario } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nexport const orderSupportScenario = pikkuScenario<\n { value?: number },\n { doubled: number; message: string }\n>({\n title: 'Order support (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, data, { scenario, actors }) => {\n if (!actors?.shopper || !actors?.support) {\n throw new Error(\n 'orderSupportScenario needs run actors (shopper + support) — run via `pikku scenario run <environment>`'\n )\n }\n\n const doubled = await scenario.do(\n 'shopper doubles their order',\n 'doubleValue',\n { value: data?.value ?? 21 },\n { actor: actors.shopper }\n )\n\n const settled = await scenario.expectEventually(\n 'support sees the greeting settle',\n 'formatMessage',\n { greeting: 'Hello', name: 'Support' },\n (out: { message: string }) => out.message.length > 0,\n { actor: actors.support, within: '5s', interval: 50 }\n )\n\n return { doubled: doubled.result, message: settled.message }\n },\n})\n```\n\nA scenario takes the same config fields as a workflow (`title`, `description`, `tags`, `input`/`output`, `auth`, `permissions`, `middleware`, `version`, …). The third argument is the scenario context: `{ scenario, actors }`.\n\n### The scenario API\n\n| Call | Purpose |\n| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |\n| `scenario.do(step, rpc, data, { actor })` | Run an RPC as that actor. The step name is what appears in the run output. |\n| `scenario.expectEventually(step, rpc, data, predicate, { actor, within, interval })` | Poll until `predicate(out)` passes or `within` elapses. For anything asynchronous — queues, workers, eventual state. |\n| `scenario.expectError(step, rpc, data, { actor, matches })` | Assert the call **fails**. For fault injection and negative paths. |\n| `scenario.expectService(step, 'service.method', { actor, calledWith })` | Assert a stubbed service was called. Requires the server to run with `--test`. |\n| `scenario.expectScore(step, runId, scorer, { atLeast, atMost, reference })` | Grade a finished agent run with a declared scorer and assert the score. See below. |\n| `scenario.given(stepName, step, data, { actor })` | Run a declared `pikkuScenarioStep` as setup. `when` is the same call; `then` also makes the step's bindings witnesses. |\n| `scenario.runScheduledTask(name)` | Fire a wired scheduler on the target now, rather than waiting for its cron. |\n\n`expectEventually` is **scenario-only**. Calling it from a `pikkuWorkflowFunc` is a critical inspector error (`PKU675`) pointing you at `pikkuScenario`.\n\nPrefer `expectEventually` over sleeping.\n\n### Asserting on an agent's answer (`expectScore`)\n\nAn agent's output is not comparable to a fixed string, so it is graded rather\nthan matched. Declare the rubric with `pikkuAIScorer` (grades in code) or\n`pikkuAIJudge` (grades with a model) in a `*.scorer.ts` file, name it on the\nagent's `scorers`, then assert on the run the scenario just triggered:\n\n```typescript\nconst { runId } = await scenario.when('asks for a summary', 'runAssistant', {\n prompt: data.prompt,\n}, { actor: actors.user })\n\nawait scenario.expectScore('answered briefly', runId, 'brevity', { atLeast: 0.8 })\n```\n\nThe default bound is `atLeast: 0.5`, so an unqualified `expectScore` still fails\na run the scorer graded zero. `atMost` is for a rubric where high is the failure\n(sycophancy, verbosity). `reference` supplies the answer key a\n`requiresReference` judge grades against — live traffic has none, so such a\njudge is only ever reachable from a scenario.\n\nGrading goes through the `pikkuScenarioGradeRun` instrumentation RPC on the\nserver under test, which grades from the snapshot the runtime kept at the end of\nthe run. Two consequences: the run must have happened on **that** server and be\nrecent, and the grade is returned to the scenario rather than recorded — a\ntest's score never lands among the production figures. Sampling is ignored, so a\nscorer set to grade 1% of live traffic still grades every scenario run.\n\nTag any scenario whose scorer is a judge `ai-live`: it costs a model call, and\nthe default suite excludes it.\n\n### Setup and teardown (`before` / `after`)\n\nA scenario config takes `before` and `after`. Both have the **same signature as `func`** — `(services, data, wire)` — with the return value discarded:\n\n```typescript\nconst resetsCredentials = async (_services, _data, { actors }) => {\n await actors!.admin!.invoke('resetCredentials', {})\n}\n\nexport const credentialScenario = pikkuScenario({\n title: 'A credential is loaded on first use',\n tags: ['scenario', 'credential'],\n before: resetsCredentials,\n after: removesInstalledAddon,\n func: async (services, data, { scenario, actors }) => {\n /* … */\n },\n})\n```\n\n| Rule |\n| -------------------------------------------------------------------------------------------------------- |\n| `before` throwing skips the body and fails the run — but `after` still runs. |\n| `after` always runs, in a `finally`, whether the scenario passed or failed. |\n| `after` throwing fails a run that would otherwise have passed. |\n| `after` throwing on an already-failed run attaches as the `cause` and never replaces the original error. |\n| Neither runs when the run is suspended or waiting — teardown only fires at a terminal outcome. |\n| Hooks are **not** ladder rows. The runner records nothing for them; a failure is labelled by phase. |\n\nA hook reaches the app the same way the body does: through `wire.actors`. If you want cleanup to be _visible_ on the ladder, make it an ordinary `scenario.then(...)` instead.\n\nHooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs — a workflow is durable and resumable, so a callback that reran on every replay would have no honest meaning.\n\n### Grouping scenarios (`pikkuFeature`)\n\n`pikkuFeature` groups scenarios the way gherkin's `Feature:` groups `Scenario:`. Scenarios are referenced by **imported identifier**, so a renamed or deleted scenario is a compile error rather than a silent skip:\n\n```typescript\nimport { pikkuFeature } from '#pikku/workflow/pikku-workflow-types.gen.js'\nimport {\n credentialLazyLoadScenario,\n credentialRoundTripScenario,\n} from './credential.scenario.js'\n\nexport const credentialFeature = pikkuFeature({\n name: 'Credential API',\n description: 'Credentials resolve lazily and are scoped per user',\n tags: ['credential'],\n before: startsMockOAuthServer,\n after: stopsMockOAuthServer,\n scenarios: [\n credentialLazyLoadScenario,\n ...['stripe', 'google', 'hmac-key'].map((name) => ({\n scenario: credentialRoundTripScenario,\n data: { name },\n })),\n ],\n})\n```\n\n| Rule |\n| --------------------------------------------------------------------------------------------------------------------------------------------- |\n| The **export identifier is the feature's id**; `name` is the human-readable label. Both must be exported or the build fails. |\n| A `{ scenario, data }` entry is gherkin's `Examples:` — one run per entry. `data` is typed against that scenario's input. |\n| Feature hooks run **once around the whole group** (`before → a → b → c → after`), _not_ per scenario. `after` runs in a `finally`. |\n| There is deliberately **no `Background:`**. Per-scenario setup is the scenario's own `before`, referencing a shared function. |\n| A scenario's effective tags are its own **plus** the feature's, so `--tags credential` selects through the feature. |\n| A scenario need not belong to a feature — one with no input still runs standalone. |\n| Membership is resolved by **object identity** at runtime, which is why a loop works and why a scenario built inline in a feature is an error. |\n\nThe **feature is the run unit**: `--flows` on a scenario whose every feature entry carries `data` errors and names the features containing it, because the feature is what supplies that data. Use `--features` for those. A scenario referenced bare anywhere, or in no feature at all, still runs standalone.\n\n### Steps describe intent, not actions\n\nA scenario records what someone was **trying to do**, never the keystrokes they used to do it. This is the one decision that determines whether a suite survives its first redesign, and it applies to every step name you write.\n\n| Action ladder — wrong | Intent ladder — right |\n| ----------------------------------- | --------------------------------------------------- |\n| `Given opens /shop` | `Given the shopper is browsing the shop` |\n| `When clicks the category filter` | `When the shopper buys the £5 strawberry milkshake` |\n| `And clicks \"Drinks\"` | `Then it is in their basket` |\n| `And clicks the first product card` | |\n| `And clicks Add to basket` | |\n| `Then sees \"1 item\"` | |\n\nThree things go wrong with the left-hand column, and all three are expensive:\n\n- **A layout change rewrites every scenario that touched that screen.** In the right-hand column it rewrites one function.\n- **The report is the deliverable.** `buys the £5 strawberry milkshake` is readable by someone who has never seen the app; `clicks [data-testid=add]` tells them nothing about whether the product works.\n- **An action step cannot arrive on its own.** It assumes the previous click left the browser somewhere, so the scenario only runs front-to-back, as a whole, in one order.\n\nSo there are three layers, and only two of them are named in the report:\n\n| Layer | What it is | On the ladder |\n| -------------------------- | -------------------------------------- | ---------------- |\n| Scenario | The flow, written as intents | yes — the ladder |\n| Step (`pikkuScenarioStep`) | One intent | yes — one row |\n| Utility | An ordinary TS function over `browser` | no |\n\nUtilities are **not steps**. They are plain exported functions, they take the browser handle, and they hold the clicking:\n\n```typescript\n// shop.browser.ts — shared actions. Not steps: nothing here is an intent.\nimport type { PikkuBrowserWire } from '@pikku/core/workflow'\nimport type {} from '@pikku/playwright'\n\n/** Arrive on the shop, from wherever the browser happens to be. */\nexport const ensureOnShop = async (browser: PikkuBrowserWire) => {\n if (!new URL(browser.page.url()).pathname.startsWith('/shop')) {\n await browser.goto('/shop')\n }\n await browser\n .locate({ testId: 'product-grid' })\n .first()\n .waitFor({ state: 'visible' })\n}\n\nexport const searchFor = async (browser: PikkuBrowserWire, query: string) => {\n await browser.locate({ testId: 'shop-search' }).first().fill(query)\n await browser.page.keyboard.press('Enter')\n}\n\nexport const filterByCategory = async (\n browser: PikkuBrowserWire,\n category: string\n) => {\n await browser.locate({ testId: 'category-filter' }).first().click()\n await browser\n .locate({ testId: 'category-option', where: { 'data-category': category } })\n .first()\n .click()\n}\n\nexport const addToBasket = async (browser: PikkuBrowserWire, name: string) => {\n const card = browser\n .locate({ testId: 'product-card', containing: name })\n .first()\n await card.waitFor({ state: 'visible' })\n await card.locate('[data-testid=add-to-basket]').click()\n}\n```\n\nThe step composes them, and it is the step — one row — that the report shows:\n\n```typescript\nexport const buysTheItem = pikkuScenarioStep<\n { name: string },\n { name: string }\n>({\n name: 'buysTheItem',\n description: 'finds one item in the shop and puts it in the basket',\n template: 'buys the {name}',\n // One intent, one implementation per surface an actor can drive it through.\n browser: async (_services, { name }, { browser }) => {\n await ensureOnShop(browser)\n await searchFor(browser, name)\n await addToBasket(browser, name)\n return { name }\n },\n default: async ({ rpc }, { name }) => {\n const item = await rpc.invoke('findItemByName', { name })\n await rpc.invoke('addToBasket', { itemId: item.id })\n return { name }\n },\n})\n```\n\nThe bindings are **alternatives**: `pikku scenario run --run browser` clicks through the shop, `--run cli` drives it over the websocket, `--run default` (the fast suite, and the default) takes the server-side path — and all of them report the same sentence.\n\n```typescript\nawait scenario.when(\n 'buys a milkshake',\n 'buysTheItem',\n { name: '£5 strawberry milkshake' },\n { actor: actors.shopper }\n)\n// reporter renders: When the shopper buys the £5 strawberry milkshake ✓ 1.2s\n```\n\n**Every intent step begins by arriving.** `ensureOnShop` is not defensive noise — it is what lets a scenario start at any step, run alone, and be reordered without touching it. It checks first and navigates only if needed, so a scenario already on the shop pays nothing. This is about the _browser's_ starting position, not the database: there is still no state reset (see above), and you still scope what you create.\n\n**The same utilities, a different intent.** A scenario about filtering has filtering as its subject, so there the filter _is_ the intent — same helper, its own step:\n\n```typescript\nexport const filtersTheShop = pikkuScenarioStep<\n { category: string },\n { shown: number }\n>({\n name: 'filtersTheShop',\n description: 'narrows the catalogue to one category',\n template: 'filters the shop by {category}',\n browser: async (_services, { category }, { browser }) => {\n await ensureOnShop(browser)\n await filterByCategory(browser, category)\n return {\n shown: await browser.locate({ testId: 'product-card' }).count(),\n }\n },\n default: async ({ rpc }, { category }) => ({\n shown: (await rpc.invoke('listItems', { categorySlug: category })).length,\n }),\n})\n```\n\nTwo scenarios, two intents, one set of utilities. That is the shape to aim for: when a helper is reused by a step whose _subject_ it is, promote it to a step there — never the reverse.\n\n**Non-browser steps need none of this.** Without a browser there is no navigation to absorb and no DOM to hide, so an intent maps to one RPC and `scenario.do` names it directly:\n\n```typescript\nconst order = await scenario.do(\n 'Shopper checks out',\n 'createOrder',\n { basketId, shippingAddress },\n { actor: actors.shopper }\n)\n```\n\nReach for a `pikkuScenarioStep` on the non-browser side only when one intent genuinely spans several RPCs, or when the step asserts something the RPC result alone does not say.\n\n### `then` bindings are witnesses, not alternatives\n\nThis is the one place the surface bindings do **not** behave like a switch, and it is the part worth reading twice.\n\nOn a `given` or `when`, the bindings are alternatives — clicking Buy and calling `createOrder` are two ways to cause one effect, so exactly one runs.\n\nOn a `then`, they are not two implementations of one assertion. They are two _different claims_:\n\n| binding | what it actually proves |\n| --------- | -------------------------------------------------------------- |\n| `default` | the order row says `paid` — the system of record is right |\n| `browser` | the confirmation panel says paid — the truth reached the human |\n\nThe gap between them is the bug nobody catches: 200 OK, database correct, user still watching a spinner. So a `then` runs **every** binding it declares and fails if they disagree.\n\n```typescript\nexport const seesTheOrderConfirmed = pikkuScenarioStep<\n { orderId: string },\n { status: string }\n>({\n name: 'seesTheOrderConfirmed',\n template: 'sees order {orderId} confirmed',\n // Both run on `--run browser`. Each returns what it observed, and the runner\n // compares them — so this fails when the page disagrees with the database.\n browser: async (_services, { orderId }, { browser }) => ({\n status: await browser\n .locate({ testId: 'order-status', where: { 'data-order': orderId } })\n .getAttribute('data-status'),\n }),\n default: async ({ rpc }, { orderId }) => ({\n status: (await rpc.invoke('getOrder', { orderId })).status,\n }),\n})\n```\n\nThree rules follow, and they are the ones that get broken:\n\n- **A browser witness must observe on the page.** One that quietly calls an RPC to check the result is worse than no binding at all — it reports a tick for a surface it never looked at.\n- **Return what you observed, don't just assert.** A witness returning a value lets the runner diff the two. A witness that only throws still works, but it can never disagree with anything, so it proves less. Read structured state with `where` on the test-id selector rather than parsing translated copy.\n- **A step with no binding for the run's surface is counted, not excused.** `--run browser` prints `n/m steps ran on browser` over _every_ step, so an action that quietly fell back to the server lowers the number just as an assertion does. A `then` that fell back is additionally named — `--strict` fails on those, because a sentence saying the actor saw something nobody looked at is a different problem from a shortcut. Not being in the UI _is_ the finding: do not add a browser binding that fakes it.\n\n**Always give a `then` a `default` witness.** It is the floor every run can fall back to, and an assertion with no witness the run can execute is fatal (`ScenarioNoWitness`) — not a coverage gap. The distinction is the point: a `then` checked server-side under `--run browser` did happen, it just wasn't seen where the prose claims; one checked nowhere never happened at all, and without the error it would return `undefined` and render as a tick. A browser-only `then` is therefore a step that fails the fast suite, which is rarely what you want.\n\n**Every scenario must assert.** A flow of only `given`/`when` is a PKU680 critical — it proves nothing threw. Since coverage counts every step, an assertion-free ladder of browser-bound actions would score a perfect `3/3` while checking nothing, so clicking through the UI and never looking at the result is the cheapest way to fake the number. The rule closes that.\n\nAssertions with no possible browser witness are a different thing and should not be written as a `then`: \"the audit log recorded it\" is a system check, and \"the receipt email arrives\" is `expectEventually`, which is always out-of-band and always server-side.\n\n### Declared steps (`pikkuScenarioStep`)\n\n`scenario.do` can only name an RPC. A **step** is a named, typed unit of scenario behaviour whose body is an ordinary pikku function — so it can call several RPCs as its actor, assert, or drive a browser.\n\n```typescript\nimport { pikkuScenarioStep } from '#pikku/workflow/pikku-workflow-types.gen.js'\nimport { requireActor } from '@pikku/core/workflow'\n\nexport const buysAnApple = pikkuScenarioStep<\n { qty: number },\n { orderId: string }\n>({\n name: 'buysAnApple',\n description: 'buys an apple',\n template: 'buys {qty} apples',\n default: async (_services, { qty }, { scenarioStep }) => {\n return await requireActor(scenarioStep).invoke('placeOrder', { qty })\n },\n})\n```\n\nA step's body always lives under a **surface binding** — `default`, `browser` or\n`cli` — never under a `func`. Declaring none throws at load time: at minimum give\nit a `default`.\n\n```typescript\nawait scenario.given(\n 'buys an apple',\n 'buysAnApple',\n { qty: 1 },\n { actor: actors.shopper }\n)\n// reporter renders: Given the shopper buys 1 apples ✓ 412ms\n```\n\nRules that bite:\n\n- **The step is referenced by its typed string name, not by importing the const** — exactly like `workflow.do`. The name is the step's `pikkuFuncId` and is checked against the generated step map. A non-literal target is a critical error (`PKU678`).\n- **Steps are not RPCs.** They are deliberately never network-callable — a browser-driving step must not be.\n- **`actor.invoke` is typed over the exposed RPC map**, so the name and the payload are checked and the result comes back narrowed — no cast. `actor.invokeRaw(name, data, { headers })` is the same call reporting `{ status, ok, body }` instead of throwing; use it whenever the refusal _is_ the assertion.\n- **`actor` and `env` are optional on the wire**, because a pure assertion step needs neither. Narrow them with `requireActor(scenarioStep)` and `requireScenarioEnv(scenarioStep)` from `@pikku/core/workflow` rather than a local guard — both name the step and say what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.\n- **Steps default to `retries: 0`**, unlike ordinary workflow steps. Retrying a failed assertion is wrong; pass `retries` explicitly if a step is genuinely flaky-by-nature.\n- **Step results are persisted**, so return JSON-serialisable data — never a `Locator` or a client object.\n- **`description` documents the step; `template` is what the report renders.** `template`'s `{placeholders}` are filled from the input the step was called with, so one step reads differently for each call — `sees {state} addon {packageName}` reports as \"sees available addon @pikku/addon-stripe\". Reflect every input field in the template, and type the values so they read as words (`state?: 'installed' | 'available'`, not `installed?: boolean`). A placeholder with no value renders as nothing and the whitespace collapses.\n- Prose precedence is `options.description` → the step's `template` → the step's own `description` → the positional step name. Repeated names get `#1`, `#2` ordinals, so a `for` loop over a data set is how you write a Scenario Outline. A loop-generated step name is not statically known, so it is matched back to its declaration by step function instead — which works as long as that function's call sites agree on their phase, actor and prose. Two call sites that disagree make the loop step report under its bare runtime name.\n\n### Browser steps\n\nDeclaring a `browser` binding is the whole switch: inside that binding `wire.browser` is guaranteed present and non-optional, and a step without one never sees a browser at all. There is nothing to null-check.\n\nA `browser` binding gets a session bound to **its actor**, signed in through the same `signInPath` + `SCENARIO_ACTOR_SECRET` path the HTTP actors use, so the browser and the RPC calls are one identity. Calling such a step without an actor is a critical error (`PKU677`).\n\nBrowser steps are where **intent, not actions** earns its keep: the step is one intent, the clicking lives in shared utilities, and the step arrives before it acts. Write the mechanics below into utilities and keep the step body to three or four calls that read as a sentence.\n\n```typescript\nexport const opensTheCart = pikkuScenarioStep<{ path: string }, { url: string }>(\n {\n name: 'opensTheCart',\n description: 'opens the cart',\n browser: async (_services, { path }, { browser }) => {\n await browser.goto(path)\n return { url: browser.page.url() }\n },\n default: async ({ rpc }) => ({ url: (await rpc.invoke('getCart', {})).url }),\n }\n)\n```\n\n- Install `@pikku/playwright` and `@playwright/test`, and import `@pikku/playwright` once (`import type {} from '@pikku/playwright'`) so `browser.page` is a typed Playwright `Page`. Without it you still get the structural `goto`/`screenshot` handle.\n- The environment needs an `appUrl` beside its `apiUrl`. `pikku scenario run` fails fast before running anything if a browser scenario has no `appUrl` or the driver is not installed.\n- `pikku scenario run <env> --no-browser` **skips** scenarios containing browser steps and reports them as skipped — it does not fail them. That is how a machine with no browser stays green.\n- Playwright auto-waits; do not wrap `page.click` in `expectEventually`.\n\n## Configuration\n\nPersonas, actors and environments live in `pikku.config.json`:\n\n```json\n{\n \"scenarios\": {\n \"personas\": {\n \"shopper\": { \"description\": \"Buys things here\", \"primary\": true },\n \"support\": {\n \"description\": \"Answers for the shop\",\n \"proficiency\": \"power\"\n },\n \"reminders\": {\n \"description\": \"The shop chasing abandoned carts\",\n \"kind\": \"system\"\n }\n },\n \"actors\": {\n \"shopper\": {\n \"email\": \"shopper@actors.local\",\n \"name\": \"Shopper\",\n \"jobTitle\": \"First-time buyer\",\n \"personality\": \"Impatient shopper who abandons slow checkouts\"\n },\n \"shopperB\": { \"persona\": \"shopper\", \"email\": \"shopper-b@actors.local\" }\n },\n \"environments\": {\n \"local\": {\n \"apiUrl\": \"http://localhost:4077\",\n \"signInPath\": \"/api/auth/sign-in/actor\"\n }\n }\n }\n}\n```\n\n### Personas and actors\n\nA **persona** is a kind of person; an **actor** is one body that signs in as one. Above, `support` is declared only as a persona — its actor is materialised (`support@actors.local`), so `actors.support` works without an `actors` entry. Write an actor by hand only when you need something the materialised one wouldn't have:\n\n- a **real email or personality** for it, like `shopper`;\n- a **second body of the same persona**, like `shopperB` — which is what tenant isolation, peer sharing, and \"another member's row\" scenarios are made of. Two actors of one persona must be two different users, so **two actors sharing an email is an error**.\n\nA persona holds only what is true of that kind of person for the app's whole lifetime — `description`, `primary` (whose experience the product is), `kind`, `proficiency`. What someone is trying to get done, and the circumstances they are doing it in, belong to the **scenario**, not to them.\n\n`kind: \"system\"` is the app acting on its own — a schedule, a cleanup, a send. It gets **no actor**: there is nobody to sign in. Give it one by hand only if it genuinely has a service account.\n\nAn actor with no `persona` is its own persona, so a project that never declares any keeps working unchanged.\n\n- `environments.<name>.apiUrl` is required. `signInPath` defaults to `/auth/sign-in/actor`, `rpcPath` to `/rpc`.\n- **`SCENARIO_ACTOR_SECRET` is an environment variable and never goes in `pikku.config.json`.** It signs actors in. `pikku scenario run` throws without it; a server auto-building actors warns and runs without them.\n\n## Running\n\n```bash\npikku scenario list # features with their scenarios indented, then ungrouped scenarios\nSCENARIO_ACTOR_SECRET=… pikku scenario run local\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --flows orderSupportScenario\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --features credentialFeature\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --tags smoke,scenario\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --spawn --no-browser --exclude-tags ai-live\n```\n\n`run` takes the environment as a **required positional** — the key from `environments`. Every filter narrows the same plan, so narrowing a feature to two of its five scenarios still runs the feature's hooks exactly once around those two.\n\n| Flag | Effect |\n| ----------------------- | --------------------------------------------------------------------------------------- |\n| `--flows` / `-f` | Comma-separated scenario names |\n| `--features` | Comma-separated feature ids |\n| `--tags` / `-t` | Match-any tag filter |\n| `--exclude-tags` | Hold tags back — unless the flow is named directly with `--flows` |\n| `--run <surface>` | `default` (the default), `browser`, or `cli` |\n| `--no-browser` | Shorthand for `--run default`; scenarios with browser steps report as **skipped** |\n| `--strict` | Fail, rather than pass, a `then` with no witness on the run's surface |\n| `--spawn` / `--keep-alive` | Start `pikku dev` on the environment's apiUrl for the run; optionally leave it up |\n| `--api-url` / `--app-url` | Override the environment's URLs — for a target that only exists at run time |\n| `--trace` | Keep every stack frame on failure (default shows only the project's own) |\n| `--coverage` | Reset/snapshot server coverage per scenario |\n\nOutput is `PASS <name> (<ms>) → <output>` / `FAIL <name> (<ms>): <error>`, then `N/M scenarios passed against '<env>'`. A scenario inside a feature is named `<Feature> › <scenario> <data>`.\n\n**Exit code is 1** if any scenario fails _or_ if no scenario matched the filter — a typo'd `--flows` is a hard error, not a silent zero-run pass. It throws outright on an unknown environment, an unknown flow name, or a missing `SCENARIO_ACTOR_SECRET`.\n\n## Coverage\n\nCoverage is attributed by running scenarios against a server that is collecting it. It is **not** derived from unit tests.\n\nPrerequisite in `pikku.config.json`:\n\n```bash\npikku enable scenarios # sets scaffold.scenarios = true (session required)\npikku enable scenarios --noAuth # sets scaffold.scenarios = { \"auth\": false }\n```\n\n`scaffold.scenarios` is a boolean or `{ auth?, path? }`. The legacy string forms\n(`\"auth\"` / `\"no-auth\"`) are **rejected by the config loader**, not reinterpreted —\nunder a shape where a string could be a path, silently reading one as a flag\nwould be worse than failing.\n\n`scaffold.scenarios` generates the coverage and stub RPCs into your project (`pikkuScenarioTakeLiveCoverage`, `pikkuScenarioResetLiveCoverage`, `pikkuScenarioResetStubs`, `pikkuScenarioGetStubCalls`), so scenario runs work against any server. The coverage RPC reads `<outDir>/function/pikku-functions-meta-verbose.gen.json` off disk at request time — codegen always writes it, but it has to be deployed alongside the app or the RPC returns `null`.\n\n```bash\npikku dev --coverage # V8 precise coverage, in-process\npikku dev --coverage --test # also enable stubs (needed for expectService)\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --coverage\n```\n\nThe run resets coverage before each scenario and snapshots after, writing **`<outDir>/coverage/scenario-coverage.json`**:\n\n```jsonc\n{\n \"generatedAt\": \"…\",\n \"environment\": \"local\",\n \"scenarios\": {\n \"<name>\": {\n /* FunctionCoverageReport */\n },\n },\n}\n```\n\nCoverage is best-effort: it disables itself with a warning if the server is not collecting or the first actor cannot invoke, and it needs at least one configured actor. If you get no coverage, check those first.\n\n**There is no AI-prompt output.** The old `--ai-out` flag died with `pikku tests`; nothing replaced it. To find what needs work, read `scenario-coverage.json` yourself and cross-reference `pikku meta functions list` for input/output schemas.\n\n### Filling coverage\n\n1. `pikku scenario run <env> --coverage`, then read `<outDir>/coverage/scenario-coverage.json` to see what is unexercised.\n2. `pikku meta functions list` for those functions' schemas.\n3. Write a `pikkuScenario` that reaches them **through a real user flow** with an actor — not a scenario per function. Scenarios are flows; coverage is a consequence.\n4. Re-run to confirm.\n\n## Unit tests for pure logic\n\nScenarios are the repo-idiomatic way to test functions, and the only thing that contributes to live coverage. For pure logic with heavy branching, a plain unit test calling `func` directly is still valid and cheap:\n\n```typescript\nimport { describe, test } from 'node:test'\nimport assert from 'node:assert'\n\ndescribe('createTodo', () => {\n test('creates a todo', async () => {\n const services = {\n todoStore: { add: async (title: string) => ({ id: '1', title }) },\n }\n const result = await createTodo.func(services as any, { title: 'Buy milk' })\n assert.equal(result.title, 'Buy milk')\n })\n})\n```\n\n```bash\nnode --import tsx --test src/**/*.test.ts\n```\n\nServices are plain objects — a Pikku function is pure business logic, so a mock is just the shape the function destructures. Build real services via the `pikkuServices` / `pikkuWireServices` factories when a test needs them.\n\n## Red flags\n\n| Smell | Why it's wrong |\n| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| `pikku tests …` | Removed in #865. Use `pikku scenario`. |\n| `.feature` files / Gherkin for function tests | Scenarios are TypeScript, not Gherkin. The in-process cucumber function world was deleted. |\n| `scenario.do(...)` with no `{ actor }` | Throws. Every step runs as somebody. |\n| A scenario per function | Scenarios are user flows. One flow covers many functions; that is the point. |\n| Assuming a clean database | There is no state reset — it may be a staging server. Scope what you create. |\n| `sleep()` before asserting | Use `expectEventually`. |\n| A step named `clicksAddToBasket` / `opensThePage` | That is an action, not an intent. Name the step for what the actor wanted; put the clicking in a utility. |\n| A browser step that assumes it is already on a page | It can then only run mid-flow. Arrive first — check the URL, navigate if needed. |\n| A `browser` binding guarding `if (!browser)` | The binding guarantees it. The guard hides the real error, which is a missing actor (`PKU677`). |\n| A step with a `func:` instead of a surface binding | There is no `func` on a step. Bodies live under `default` / `browser` / `cli`; a step with none throws at load. |\n| `expectEventually` in a `pikkuWorkflowFunc` | `PKU675` — scenario-only. |\n| Coverage silently 0 | Server not run with `--coverage`, verbose functions meta not deployed, `scaffold.scenarios` unset, or no actors configured. |\n\n`@pikku/cucumber` is a **browser/e2e** harness (`Actor`, `BrowserWorld`, `PersonaData`, `DbUtils`) — out of scope here.\n\nSee `pikku-concepts` for the core mental model.\n", "pikku-schedule/SKILL.md": "---\nname: pikku-schedule\ndescription: >-\n Use when setting up in-memory cron scheduling in a Pikku app. Covers InMemorySchedulerService\n for running scheduled tasks. TRIGGER when: code uses InMemorySchedulerService,\n PikkuTaskScheduler, or user asks about in-memory scheduling, cron jobs without external\n dependencies, or @pikku/schedule. DO NOT TRIGGER when: user asks about cron wiring (use\n pikku-cron) or queue-based scheduling with BullMQ/PgBoss (use pikku-queue).\ninstallGroups: [core]\n---\n\n# Pikku Schedule (In-Memory Scheduler)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/schedule` provides an in-memory cron scheduler for running Pikku scheduled functions without external dependencies like Redis or PostgreSQL.\n\n## Installation\n\n```bash\nyarn add @pikku/schedule\n```\n\n## API Reference\n\n### `InMemorySchedulerService`\n\n```typescript\nimport { InMemorySchedulerService } from '@pikku/schedule'\n\nconst schedulerService = new InMemorySchedulerService()\nawait schedulerService.start() // registers a CronJob per wired scheduled task\n```\n\nIt implements core's `SchedulerService` on two mechanisms: `cron` for the\nrecurring tasks you declared with `wireScheduler` (see `pikku-cron`), and\n`setTimeout` for one-off delayed RPCs. Both live in process memory, so nothing\nsurvives a restart and nothing is shared between instances — fine for\ndevelopment and a single-instance deployment, wrong for anything else.\n\n`PikkuTaskScheduler` is a deprecated alias for the same class.\n\n### Scheduling a one-off RPC\n\n```typescript\nconst taskId = await schedulerService.scheduleRPC('5m', 'sendReminder', data, session)\nawait schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null\nawait schedulerService.getAllTasks() // pending one-offs only\nawait schedulerService.unschedule(taskId) // true when it was still pending\n```\n\nThe delay is milliseconds or a duration string (`'30s'`, `'5m'`, `'2h'`). This is\nalso the mechanism a workflow's delayed steps use, which is why a workflow that\nsleeps needs a `schedulerService` registered.\n\n## Usage Patterns\n\n### Basic Setup\n\nThe scheduler is a singleton service under the name **`schedulerService`**, and\nit is started in your server bootstrap — declaring it without calling `start()`\nregisters no cron jobs, so nothing ever fires:\n\n```typescript\n// start.ts\nimport { InMemorySchedulerService } from '@pikku/schedule'\n\nconst schedulerService = new InMemorySchedulerService()\nconst singletonServices = await createSingletonServices(config, {\n schedulerService,\n})\n\nawait appServer.start()\nawait schedulerService.start()\n```\n\nCall `close()` on shutdown — it stops every cron job and clears pending timers.\n\nFor distributed or persistent scheduling, take the scheduler service off the\nqueue factory instead (`bullFactory.getSchedulerService()`,\n`pgBossFactory.getSchedulerService()`) and register it under the same name. See\n`pikku-queue`.\n", "pikku-schema-ajv/SKILL.md": "---\nname: pikku-schema-ajv\ndescription: >-\n Use when setting up JSON schema validation with AJV in a Pikku app. Covers AjvSchemaService for\n request/response validation. TRIGGER when: code uses AjvSchemaService, user asks about AJV, JSON\n schema validation, or @pikku/schema-ajv. DO NOT TRIGGER when: user asks about Cloudflare Workers\n schema validation (use pikku-schema-cfworker).\ninstallGroups: [core]\n---\n\n# Pikku Schema AJV (JSON Schema Validation)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/schema-ajv` provides JSON schema validation using [AJV](https://ajv.js.org/). Implements the `SchemaService` interface from `@pikku/core`. This is the default schema validator for Node.js environments.\n\n## Installation\n\n```bash\nyarn add @pikku/schema-ajv\n```\n\n## API Reference\n\n### `AjvSchemaService`\n\n```typescript\nimport { AjvSchemaService } from '@pikku/schema-ajv'\n\nconst schema = new AjvSchemaService(logger: Logger)\n```\n\n**Methods:**\n\n- `compileSchema(name: string, schema: any): void` — Compile and register a JSON schema under `name`\n- `validateSchema(schemaName: string, json: any): void` — Validate data against a compiled schema (throws on failure)\n- `getSchemaNames(): Set<string>` — Get all registered schema names\n- `getSchemaKeys(schemaName: string): string[]` — Top-level property keys, or `[]` if the schema has no `properties`\n\nThe first argument is the **name**, the second the schema — the parameter is\ncalled `schema` in the source, which reads backwards.\n\n### Behaviour that matters\n\n- **Registration is name-keyed and never re-compiles.** A second\n `compileSchema('X', …)` with a different schema is a no-op; the first one wins\n for the process lifetime. `@pikku/schema-cfworker` *does* recompile on a\n changed value, so a dev hot-reload after codegen picks up a changed schema\n there but not here — restart the process instead.\n- **AJV is a module-level singleton**, shared by every `AjvSchemaService` you\n construct, so compiled schema names are global to the process.\n- **`useDefaults: true` mutates the validated object**, filling in schema\n defaults in place. `coerceTypes: false`, so a query-string `\"1\"` will not\n become `1` — the wiring layer is what coerces, not this service.\n- `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)\n are enforced.\n- A failed validation throws `UnprocessableContentError` (a 422). A *missing*\n schema throws a bare string, `Missing validator for <name>` — not an `Error`,\n so `catch (e) { e.message }` reads `undefined`. That normally means codegen\n didn't run.\n\n## Usage Patterns\n\n### With Pikku Services\n\n```typescript\nimport { AjvSchemaService } from '@pikku/schema-ajv'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const schema = new AjvSchemaService(logger)\n return { config, logger, schema }\n})\n```\n\nPikku automatically uses the schema service to validate function inputs and outputs when schemas are defined in your function definitions.\n", "pikku-schema-cfworker/SKILL.md": "---\nname: pikku-schema-cfworker\ndescription: >-\n Use when setting up JSON schema validation for Cloudflare Workers in a Pikku app. Covers\n CFWorkerSchemaService as a lightweight alternative to AJV. TRIGGER when: code uses\n CFWorkerSchemaService, user asks about schema validation on Cloudflare Workers, or\n @pikku/schema-cfworker. DO NOT TRIGGER when: user asks about AJV schema validation (use\n pikku-schema-ajv).\ninstallGroups: [core, fabric]\n---\n\n# Pikku Schema CFWorker (Cloudflare Workers Validation)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/schema-cfworker` provides JSON schema validation using [@cfworker/json-schema](https://github.com/cfworker/cfworker), a lightweight validator compatible with Cloudflare Workers (no `eval` or `new Function`). Implements the `SchemaService` interface from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/schema-cfworker\n```\n\n## API Reference\n\n### `CFWorkerSchemaService`\n\n```typescript\nimport { CFWorkerSchemaService } from '@pikku/schema-cfworker'\n\nconst schema = new CFWorkerSchemaService(logger: Logger)\n```\n\n**Methods:**\n\n- `compileSchema(name: string, schema: any): void` — Compile and register a JSON schema under `name`\n- `validateSchema(schemaName: string, json: any): void` — Validate data against a compiled schema (throws on failure)\n- `getSchemaNames(): Set<string>` — Get all registered schema names\n- `getSchemaKeys(schemaName: string): string[]` — Top-level property keys, or `[]` if the schema has no `properties`\n\n### Where it differs from AJV\n\nThese two are not drop-in equivalents, and the differences are the kind that\nsurface as behaviour changes rather than compile errors:\n\n- **No `useDefaults`.** AJV fills schema defaults into the validated object in\n place; this validator does not. A field you relied on being defaulted arrives\n `undefined` on Workers.\n- **It re-compiles when the schema value changes.** AJV caches by name forever;\n here a `compileSchema` with a different value for the same name replaces the\n validator, which is what lets a dev hot-reload pick up regenerated schemas.\n- **Each validator gets a deep clone of the schema** (`@cfworker/json-schema`\n mutates what it is given, which throws on a frozen generated object).\n- A compile failure throws `Error('Failed to compile schema: <name>')` with the\n underlying cause swallowed — check the schema by hand when you see it.\n\nA failed validation throws `UnprocessableContentError` (422) with the validator\nerrors joined; a *missing* schema throws a bare string, `Missing validator for\n<name>`, not an `Error`.\n\n## Usage Patterns\n\n### With Cloudflare Workers\n\n```typescript\nimport { CFWorkerSchemaService } from '@pikku/schema-cfworker'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const schema = new CFWorkerSchemaService(logger)\n return { config, logger, schema }\n})\n```\n\nUse this instead of `@pikku/schema-ajv` when deploying to Cloudflare Workers, as AJV uses `eval` which is not permitted in the Workers runtime.\n", "pikku-security/SKILL.md": "---\nname: pikku-security\ndescription: >-\n Use when adding authentication or session management to a Pikku app — pikkuAuth, session\n lifecycle (setSession/clearSession), built-in auth strategies (authBearer, authCookie,\n authAPIKey), or JWT setup. TRIGGER when: user asks about login, logout, session, bearer tokens,\n cookie auth, API keys, or JWT. DO NOT TRIGGER when: user asks about middleware (use\n pikku-middleware), permissions/authorization checks (use pikku-permissions), or secrets/env vars\n (use pikku-config).\ninstallGroups: [core]\n---\n\n# Pikku Security (Authentication & Sessions)\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info middleware --verbose` and `pikku info functions --verbose` to understand existing auth setup.\n2. Auth strategies live in wirings files — do not put `addHTTPMiddleware` calls inside function bodies.\n3. Validate with `pikku all --tsc` after changes — it regenerates and then type-checks in one pass, and fails on type errors. Use `--tsc-summary` for a compact one-line-per-error report.\n\nFor **middleware** (including tag middleware and service-to-service bearer auth) see `pikku-middleware`.\nFor **permissions** (pikkuPermission, pikkuAuth, per-function authorization) see `pikku-permissions`.\n\n## Session Management\n\n`session`, `setSession` and `clearSession` live on the **wire** — the function's\nthird argument — not on services. `setSession`/`clearSession` may be async\n(cookie and session-store backends write on the way out), so await them.\n\n```typescript\n// Read session in pikkuFunc (session guaranteed to exist)\nconst getProfile = pikkuFunc({\n func: async ({ db }, _data, { session }) => {\n return await db.getUser(session.userId)\n },\n})\n\n// Set session (e.g., after login)\nconst login = pikkuFunc({\n auth: false,\n func: async ({ jwt, db }, { email, password }, { setSession }) => {\n const user = await db.verifyCredentials(email, password)\n await setSession({ userId: user.id })\n return { token: jwt.sign({ userId: user.id }) }\n },\n})\n\n// Clear session (logout)\nconst logout = pikkuFunc({\n func: async ({}, _data, { clearSession }) => {\n await clearSession()\n },\n})\n```\n\n`login` is `auth: false` because the caller has no session yet — a `pikkuFunc`\nwith the default `auth` would be rejected before its body ever ran.\n\n## Built-in Auth Strategies\n\nApply these via `addHTTPMiddleware` in a wirings file:\n\n```typescript\nimport { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'\nimport { addHTTPMiddleware } from '#pikku'\n\n// JWT bearer token — reads Authorization header\naddHTTPMiddleware('*', [authBearer()])\n\n// Cookie-based sessions — re-issues the cookie when the session changes\naddHTTPMiddleware('*', [\n authCookie({\n name: 'session',\n expiresIn: { value: 30, unit: 'day' },\n options: { sameSite: 'strict' },\n }),\n])\n\n// API key — from x-api-key header or ?apiKey= query param\naddHTTPMiddleware('*', [authAPIKey({ source: 'all' })])\n```\n\nAll three share the same escape hatch: they do nothing when there is no HTTP\nrequest, or when a session is already set. That is what lets you stack several —\nwhichever runs first and finds a credential wins, and the rest step aside — and\nit is also why none of them authenticate a queue job, a scheduled task or a\nchannel message. Those need a session set another way.\n\nEach decodes its credential with the `jwt` service; without one registered, they\nsilently authenticate nobody.\n\n**`authBearer` in static-token mode.** Passing `token` switches it from decoding\na JWT to comparing (in constant time) against a fixed value — the shape to use\nfor a service-to-service caller or a demo:\n\n```typescript\nauthBearer({\n token: {\n secretId: 'AGENT_DEMO_TOKEN', // or: value: 'literal-token'\n userSession: { userId: 'demo-user' },\n },\n})\n```\n\nAn unset secret leaves the middleware inert rather than erroring, so a template\nthat ships this is safe until someone provides the secret. A malformed\n`Authorization` header (no `Bearer ` scheme) throws `InvalidSessionError` in\neither mode.\n\n**`authCookie` options.** `name`, `expiresIn` and `options` are all part of the\nconfig; `options` merges over the defaults `{ httpOnly: true, secure: true,\nsameSite: 'lax', path: '/' }`, so only override what you need. The cookie is\nre-issued after the request only when the session actually changed, which is how\na rolling session extends itself without writing a `Set-Cookie` on every\nresponse.\n\n## Complete Example\n\n```typescript\n// permissions.ts\nimport { pikkuAuth, pikkuPermission } from '#pikku'\n\nexport const isAuthenticated = pikkuAuth(async (_services, session) => !!session)\nexport const isVerified = pikkuAuth(async (_services, session) => !!session?.emailVerified)\n\n// wirings/auth.wiring.ts\nimport { authCookie } from '@pikku/core/middleware'\nimport { addHTTPMiddleware } from '#pikku'\n\naddHTTPMiddleware('*', [\n authCookie({\n name: 'session',\n expiresIn: { value: 30, unit: 'day' },\n options: {},\n }),\n])\n\n// functions/auth.functions.ts\nexport const login = pikkuFunc({\n auth: false,\n func: async ({ jwt, db }, { email, password }, { setSession }) => {\n const user = await db.verifyCredentials(email, password)\n await setSession({ userId: user.id })\n return { token: jwt.sign({ userId: user.id }) }\n },\n})\n\nexport const logout = pikkuFunc({\n func: async ({}, _data, { clearSession }) => {\n await clearSession()\n },\n})\n```\n", "pikku-services/references/audit-wire-service.md": "# Audit Wire Service\n\n`createInvocationAudit` creates a per-request `InvocationAuditLog` that buffers audit events in memory and flushes them as a batch when the function-runner calls `closeWireServices` at the end of the request. If `singletonServices.audit` is not configured (local dev without Fabric), it returns a no-op `DisabledInvocationAudit` — no crash, events are silently dropped.\n\nPair with `createAuditedKysely` to auto-capture every Kysely query as an audit event.\n\n```typescript\n// services.ts\nimport { createInvocationAudit } from '@pikku/core/services'\nimport { createAuditedKysely } from '@pikku/kysely'\n\nexport const createWireServices = pikkuWireServices(async (singletonServices, wire) => {\n const audit = createInvocationAudit(singletonServices.audit, wire)\n const kysely = singletonServices.kysely\n ? createAuditedKysely(singletonServices.kysely, { audit })\n : undefined\n return { audit, ...(kysely ? { kysely } : {}) }\n})\n```\n\nThe `audit` wire service is typed as `AuditLog` (from `@pikku/core`). Functions that emit custom events use it directly:\n\n```typescript\nconst deleteUser = pikkuFunc({\n func: async ({ audit }, { userId }) => {\n // The user identity comes from the wire session — the payload is metadata.\n await audit.write({ type: 'user.deleted', source: 'explicit', metadata: { userId } })\n // ...\n },\n})\n```\n\n`closeWireServices` (called automatically by the function-runner) invokes `audit.close()` → `singletonServices.audit.write(batch)` → platform-specific flush (e.g. CF Queue, libsql INSERT). No manual flushing needed.\n\n> **Fabric note:** Fabric provisions the audit queue and consumer worker automatically. The audit table schema is in `db/sqlite/0003-audit.sql` (starter-template). Run `pikku fabric validate` to confirm the migration is in place.\n", "pikku-services/SKILL.md": "---\nname: pikku-services\ndescription: >-\n Use when setting up dependency injection, creating custom services, or configuring the service\n layer in a Pikku app. Covers pikkuServices (singleton), pikkuWireServices (per-request),\n pikkuServerLifecycle (startup/shutdown hooks), service typing, built-in services, and\n tree-shaking. TRIGGER when: code uses pikkuServices/pikkuWireServices/pikkuServerLifecycle, user\n asks about services.ts, lifecycle.ts, dependency injection, service factories, startup or\n shutdown work, or built-in services (ConsoleLogger, JoseJWTService). DO NOT TRIGGER when: user asks\n about middleware (use pikku-middleware), auth strategies or sessions (use pikku-security),\n permissions (use pikku-permissions), or secrets/variables (use pikku-config).\ninstallGroups: [core]\n---\n\n# Pikku Services (Dependency Injection)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nPikku uses factory functions for dependency injection. Singleton services are created once at startup; wire services are created fresh per request/job/command. See `pikku-concepts` for the core mental model.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See which services existing functions use\npikku info tags --verbose # Understand project organization\n```\n\n## API Reference\n\n### `pikkuServices(factory)` — singleton services (created once at startup)\n\n```typescript\nimport { pikkuServices } from '#pikku'\nimport { ConsoleLogger } from '@pikku/core/services'\nimport { JoseJWTService } from '@pikku/jose'\n\nexport const createSingletonServices = pikkuServices(\n async (config, existingServices?) => {\n // config: your CoreConfig object\n // existingServices: optional, for chaining factories\n const logger = new ConsoleLogger()\n const database = new DatabasePool(config.database)\n await database.connect()\n const jwt = new JoseJWTService(\n async () => [{ id: 'my-key', value: config.jwtSecret }],\n logger\n )\n return { config, logger, database, jwt, books: new BookService() }\n }\n)\n```\n\n### `pikkuWireServices(factory)` — per-request services (fresh per HTTP request, queue job, CLI command, etc.)\n\n```typescript\nimport { pikkuWireServices } from '#pikku'\n\nexport const createWireServices = pikkuWireServices(\n async (singletonServices, wire) => {\n // singletonServices: all singleton services\n // wire: transport context (session, channel, etc.)\n // Pikku merges these with singleton services automatically\n return {\n userSession: createUserSessionService(wire),\n dbTransaction: new DatabaseTransaction(singletonServices.database),\n }\n }\n)\n```\n\n### `pikkuServerLifecycle(hooks)` — startup and shutdown work\n\nA service factory should **construct** services, not run startup side effects. Seeding a database, warming a cache, starting a background consumer or draining a queue belongs in lifecycle hooks, which receive the singleton services after they are built:\n\n```typescript\n// src/lifecycle.ts\nimport { pikkuServerLifecycle } from '@pikku/core'\nimport type { SingletonServices } from '../types/application-types.js'\n\nexport const lifecycle = pikkuServerLifecycle<SingletonServices>({\n beforeStart: async ({ kysely }) => {\n await runMigrations(kysely) // before the port opens\n },\n afterStart: async (services) => {\n await seedDevData(services) // server is accepting traffic\n },\n beforeStop: async ({ queueService }) => {\n await queueService.drain() // services are still alive here\n },\n afterStop: async () => {\n await releaseExternalLock() // services are ALREADY stopped\n },\n})\n```\n\nEvery hook is optional. Order is `beforeStart` → server starts → `afterStart`, then on SIGINT `beforeStop` → services stopped → server stopped → `afterStop`.\n\n**`afterStop` runs after the singleton services have been stopped.** It still receives the services object, but the services inside it are shut down — using one there is a use-after-close bug. Anything that needs a live service goes in `beforeStop`.\n\nExport **exactly one** `pikkuServerLifecycle` from anywhere in `srcDirectories`; the inspector finds it by the wrapper call, so the filename is free (`src/lifecycle.ts` by convention). It must be an exported `const` initialized with a direct call to `pikkuServerLifecycle` — a re-export or a conditional wrapper is invisible to the inspector.\n\n**Only `pikku dev` and `pikku serve` run these hooks.** If you bootstrap your own server (Express, Fastify, uWS, Lambda, Cloudflare, Next.js), no runtime adapter invokes them — put the work in your entrypoint instead.\n\n### Auto-Generated Service Manifest\n\nAfter `npx pikku all`, Pikku generates `.pikku/pikku-services.gen.ts`, a manifest of which services are actually used by wired functions:\n\n```typescript\nexport const requiredSingletonServices = {\n database: true, // used by getUser, deleteUser\n audit: true, // used by deleteUser\n cache: false, // not used by any wired function\n jwt: true, // used by auth middleware\n} as const\n\nexport type RequiredSingletonServices = Pick<\n SingletonServices,\n 'database' | 'audit' | 'jwt'\n> &\n Partial<Omit<SingletonServices, 'database' | 'audit' | 'jwt'>>\n```\n\n## Usage Patterns\n\n### Using Services in Functions\n\n**Every service must be declared in `SingletonServices` (or `Services`) in `application-types.d.ts`.** Never access a service via a body-level cast (`services as typeof services & { myService: MyService }`) — that means the type is missing. Add the import and the field to `SingletonServices`, then destructure inline in the function signature. The inspector emits `SERVICES_NOT_DESTRUCTURED` (`PKU410`) and tree-shaking breaks when the first param is a plain identifier rather than an object pattern. Never `new` a service inside a function — services arrive only via injection.\n\n```typescript\n// ✅ Correct — inline destructure, no cast\nconst getUser = pikkuFunc({\n title: 'Get User',\n func: async ({ db, logger, jwt }, { userId }) => {\n logger.info('Fetching user', { userId })\n return { user: await db.getUser(userId) }\n },\n})\n\n// ❌ Wrong — named param + body cast; inspector warns + tree-shaking breaks\nconst getUser = pikkuFunc({\n func: async (services, { userId }) => {\n const { db } = services as typeof services & { db: DbService }\n // ...\n },\n})\n```\n\n### Services Are Never Optional Inside a Function\n\n**Never write a `if (!service) throw ...` existence guard in a function body.** It is dead code, and it defeats the platform.\n\nOptionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means *\"this may not be created\"*, not *\"this may be missing at call time\"*. A service is optional precisely because **nothing destructures it**, and the generated `requiredSingletonServices` manifest therefore never marks it for creation. The moment any wired function destructures it, Pikku creates it and guarantees it is there.\n\nThe types enforce this rather than merely documenting it. The inspector records the services destructured by every wired `func`, `permissions` **and** `middleware`, and emits them as `RequiredSingletonServices`. The generated function types then default their service parameter to:\n\n```typescript\nexport type WiredSingletonServices = RequiredSingletonServices & SingletonServices\nexport type WiredServices = SecretlessServices<RequiredSingletonServices & Services>\n```\n\nThe `SecretlessServices<...>` wrapper is why `secrets` never appears in a\nfunction's services: it is stripped at the type level, not merely omitted by\nconvention. Read secrets in a service factory or middleware and hand the value\nto a service instead.\n\nso a service that is `foo?: Foo` in `SingletonServices` arrives as a non-optional `Foo` in every function, permission and middleware that uses it. There is nothing to guard against.\n\n```typescript\n// ✅ Correct — destructure and use; creation is guaranteed by the manifest\nconst listThreads = pikkuFunc({\n func: async ({ agentRunService }, { threadId }) => {\n return await agentRunService.getThreadMessages(threadId)\n },\n})\n\n// ❌ Wrong — unreachable guard; signals a misunderstanding of service wiring\nconst listThreads = pikkuFunc({\n func: async ({ agentRunService }, { threadId }) => {\n if (!agentRunService) throw new MissingServiceError('agentRunService')\n return await agentRunService.getThreadMessages(threadId)\n },\n})\n```\n\nIf a service really is conditional at runtime (e.g. an optional integration a deployment may not configure), that is a **configuration** concern: branch on config, or fail fast at startup in `services.ts` — not per-request in every function.\n\n### Dynamic Import Optimization\n\nUse the generated manifest to conditionally import heavy dependencies — only the services actually wired get instantiated:\n\n```typescript\nimport { requiredSingletonServices } from '.pikku/pikku-services.gen.js'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n\n let jwt: JWTService | undefined\n if (requiredSingletonServices.jwt) {\n const { JoseJWTService } = await import('@pikku/jose')\n jwt = new JoseJWTService(keys, logger)\n }\n\n let database: Database | undefined\n if (requiredSingletonServices.database) {\n database = await createDatabase(config.databaseUrl)\n }\n\n return { config, logger, jwt, database }\n})\n```\n\n### Audit Wire Service\n\n`createInvocationAudit` + `createAuditedKysely` add per-request audit buffering that flushes on request close (no-op if `audit` is unconfigured). For the full pattern, no-op behavior, custom-event usage, and Fabric notes, read `references/audit-wire-service.md`.\n\n### Built-in Services\n\n| Service | Package | Purpose |\n| -------------------------- | ---------------------- | -------------------------------- |\n| `ConsoleLogger` | `@pikku/core/services` | Console-based logging |\n| `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |\n| `LocalSecretService` | `@pikku/core/services` | Local development secrets |\n| `LocalVariablesService` | `@pikku/core/services` | Local environment variables |\n| `PinoLogger` | `@pikku/pino` | Structured logging via Pino |\n| `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |\n| `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |\n\n## Complete Example\n\n```typescript\n// services.ts\nimport { pikkuServices, pikkuWireServices } from '#pikku'\nimport { ConsoleLogger } from '@pikku/core/services'\nimport { JoseJWTService } from '@pikku/jose'\n\n// Custom service\nclass TodoStore {\n private todos: Map<string, Todo> = new Map()\n async create(title: string, priority: string) {\n const todo = { id: crypto.randomUUID(), title, priority, completed: false }\n this.todos.set(todo.id, todo)\n return todo\n }\n async get(id: string) { return this.todos.get(id) }\n async list() { return [...this.todos.values()] }\n async delete(id: string) { this.todos.delete(id) }\n}\n\nexport const createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const jwt = new JoseJWTService(\n async () => [{ id: 'my-key', value: config.jwtSecret }],\n logger\n )\n return {\n config,\n logger,\n jwt,\n secrets: new LocalSecretService(),\n variables: new LocalVariablesService(),\n todoStore: new TodoStore(),\n }\n})\n\nexport const createWireServices = pikkuWireServices(\n async (singletonServices, wire) => ({\n scopedLogger: new ScopedLogger(wire.session?.userId),\n })\n)\n\n// functions/todos.functions.ts — services are auto-injected\nexport const createTodo = pikkuFunc({\n title: 'Create Todo',\n func: async ({ todoStore, logger }, { title, priority }) => {\n const todo = await todoStore.create(title, priority)\n logger.info('Created todo', { id: todo.id })\n return { todo }\n },\n})\n```\n", "pikku-software-archaeology/README.md": "# pikku-software-archaeology\n\nReverse-engineers an existing repository into a **Product Blueprint**: the product intelligence hidden inside an implementation (domains, entities, commands, queries, events, policies, workflows, invariants, integrations, gaps), extracted as schema-validated JSON that a generator — in our case Pikku — can rebuild from.\n\n```\nExisting Repository → pikku-software-archaeology → .knowledge/ blueprint → new Pikku application\n```\n\nThis is **not** a code indexer or doc generator. It extracts _intent over implementation_: `POST /api/users/:id/status` becomes the command `ActivateUser`; three scattered `if (inv.user_id !== req.user.id)` checks become one `InvoiceOwnerOnly` policy with three `enforcedAt` citations.\n\n## Design decision: the AI is the parser\n\nThere is deliberately **no scanner/AST tooling** in this skill. Static extraction is brittle and per-language (the first prototype's regex scanner broke before it ran once); the analyzing model already reads every language — JS, TS, Ruby, Python, PHP, Go — follows indirection, and understands intent. Determinism lives in the **output contract** instead: fixed file names, schema-validated shapes, sorted unordered collections (sequence-bearing arrays keep their observed order), and stable concept names, all enforced by a dumb JSON validator (`scripts/validate.mjs`). The audit is expensive; that's the trade we chose.\n\n## How to run it\n\nIn Claude Code, from (or pointing at) the target repo:\n\n> Use the pikku-software-archaeology skill to extract a product blueprint from /path/to/repo\n\nThe agent then:\n\n1. **Surveys** the repo (manifests, entry points, routes, jobs, webhooks, schema, config, TODO/HACK markers) — facts only.\n2. **Excavates the test suite** — `describe`/`it` names become workflow scenarios; assertions confirm policies and upgrade confidence; rules that exist _only_ in tests are captured.\n3. **Extracts** through twelve lenses (domains, entities, commands, …) per the pipeline in `SKILL.md`. Large repos fan out subagents per lens and merge.\n4. **Cross-checks and validates**:\n ```bash\n node .claude/skills/pikku-software-archaeology/scripts/validate.mjs <repo>/.knowledge\n ```\n The validator checks every file against `references/blueprint.schema.json` plus referential integrity across files (commands reference defined domains, api surfaces map to defined commands/queries, events have producers, …).\n5. Writes `blueprint.md`, the human synthesis.\n\nOutput lands in `<repo>/.knowledge/` — 14 core JSON files + `blueprint.md` (see `SKILL.md` for the full listing). Repos with a frontend and/or non-HTTP consumer channels also get an **optional consumer-surface layer**: `interfaces.json` (every way the product is used — web UI, CLI, MCP server for AI agents, OpenAPI/REST, SDK, realtime, webhooks) plus `frontend.json` / `frontend-routes.json` / `frontend-components.json`. The frontend component inventory's `rebuild` field is the key output: it separates trivially-rebuildable standard components from the **custom-logic** pieces (charts, complex tables, editors) that must be carefully ported. Backend-only repos omit these and the validator does not complain.\n\n### Incremental re-analysis\n\nConcept names are the stable IDs. On re-run after code changes, re-extract only the affected lens/domain, diff against the existing `.knowledge/`, and leave unrelated entries verbatim. Unordered collections are sorted so diffs stay reviewable; sequence-bearing arrays stay in observed order.\n\n## How Pikku consumes the blueprint\n\nFull mapping table in `references/pikku-mapping.md`. Summary: entities → Kysely migrations + Zod schemas; commands/queries → `pikkuFunc`s; api surfaces → `wireHTTP`; policies → shared permission functions (collapsing duplicated legacy checks); system workflows → `wireScheduler`/`wireQueueWorker`/`pikkuWorkflowFunc`; integrations → injected services with `defineSecret`/`defineCredential`; test-derived scenarios → `pikkuUserFlow` stories / e2e tests. Humans resolve `migration.json.decisionsNeeded` before any generation starts.\n\n## How uncertainty is represented\n\nEvery extracted concept carries:\n\n```json\n{\n \"evidence\": [\n {\n \"file\": \"controllers/invoices.js\",\n \"lines\": \"52\",\n \"note\": \"guard: only drafts editable\"\n }\n ],\n \"confidence\": \"high\"\n}\n```\n\n- **high** — the behavior itself is in the cited code/schema/test. Generates directly.\n- **medium** — inferred from converging signals. Generates with a review marker.\n- **low** — plausible reconstruction. Never auto-generated; surfaced for human review.\n\nTwo further distinctions keep facts and guesses separate:\n\n- `events[].explicit: false` — the event was _reconstructed_ from side-effect clusters (email + status flip), not emitted by the code.\n- Comments/docs vs code: comments describe intent, code describes behavior. Disagreements are recorded as the code's behavior plus a `gaps.json` entry.\n\n## Repo layout\n\n```\npikku-software-archaeology/\n├── SKILL.md # skill definition + extraction pipeline\n├── README.md # this file\n├── references/\n│ ├── blueprint.schema.json # the output contract (JSON Schema)\n│ └── pikku-mapping.md # blueprint → Pikku primitives\n└── scripts/\n └── validate.mjs # schema + cross-file validation (node, no deps)\n```\n", "pikku-software-archaeology/references/blueprint.schema.json": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"title\": \"Product Blueprint (.knowledge/) contract\",\n \"description\": \"One schema per output file, keyed by filename under the `files` map. Every extracted concept carries evidence[] and confidence. Facts (observed in code) and inferences (reconstructed intent) must never be merged silently: confidence expresses how directly the evidence supports the claim.\",\n \"$defs\": {\n \"evidence\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"file\", \"lines\"],\n \"properties\": {\n \"file\": { \"type\": \"string\", \"description\": \"repo-relative path\" },\n \"lines\": { \"type\": \"string\", \"description\": \"e.g. '42' or '42-58'\" },\n \"note\": { \"type\": \"string\", \"description\": \"what this location shows\" }\n }\n }\n },\n \"confidence\": {\n \"type\": \"string\",\n \"enum\": [\"high\", \"medium\", \"low\"],\n \"description\": \"high = behavior directly observed in code/schema; medium = strong inference from multiple signals; low = plausible guess, needs human confirmation\"\n },\n \"conceptName\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Z][A-Za-z0-9]*$\",\n \"description\": \"PascalCase domain-language name (SendInvoice, InvoicePaid), never a route or filename\"\n }\n },\n \"files\": {\n \"product.json\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"purpose\", \"actors\", \"capabilities\", \"terminology\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"purpose\": { \"type\": \"string\", \"description\": \"1-3 sentences: what problem, for whom\" },\n \"targetUsers\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"actors\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"description\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"human\", \"system\", \"external\"] },\n \"description\": { \"type\": \"string\" }\n }\n }\n },\n \"capabilities\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" } },\n \"terminology\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"term\", \"meaning\"],\n \"properties\": { \"term\": { \"type\": \"string\" }, \"meaning\": { \"type\": \"string\" } }\n }\n },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n },\n \"domains.json\": {\n \"type\": \"object\",\n \"required\": [\"domains\"],\n \"properties\": {\n \"domains\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"description\", \"entities\", \"commands\", \"queries\", \"events\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"entities\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"commands\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"queries\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"events\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"policies\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" }, \"description\": \"policy names from policies.json owned by this domain\" },\n \"sourcePaths\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"where this domain currently lives (usually scattered)\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"entities.json\": {\n \"type\": \"object\",\n \"required\": [\"entities\"],\n \"properties\": {\n \"entities\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"description\", \"attributes\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"attributes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"type\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"type\": { \"type\": \"string\" },\n \"required\": { \"type\": \"boolean\" },\n \"notes\": { \"type\": \"string\" }\n }\n }\n },\n \"relationships\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"kind\", \"target\"],\n \"properties\": {\n \"kind\": { \"type\": \"string\", \"enum\": [\"belongs-to\", \"has-many\", \"has-one\", \"references\", \"many-to-many\"] },\n \"target\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" }\n }\n }\n },\n \"states\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"transitions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"from\", \"to\", \"trigger\"],\n \"properties\": {\n \"from\": { \"type\": \"string\" },\n \"to\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\", \"description\": \"command or event name that causes it\" }\n }\n }\n },\n \"ownership\": { \"type\": \"string\", \"description\": \"which actor/tenant owns rows of this entity\" },\n \"constraints\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"commands.json\": {\n \"type\": \"object\",\n \"required\": [\"commands\"],\n \"properties\": {\n \"commands\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"actor\", \"input\", \"preconditions\", \"effects\", \"eventsProduced\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\", \"description\": \"imperative VerbNoun: ApproveInvoice\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\", \"description\": \"how it is invoked today (route, job, webhook, cron)\" },\n \"input\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"type\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"type\": { \"type\": \"string\" },\n \"required\": { \"type\": \"boolean\" }\n }\n }\n },\n \"preconditions\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"effects\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" } },\n \"eventsProduced\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"policies\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"policy names from policies.json enforced here\" },\n \"currentImplementation\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"file paths implementing it today\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"queries.json\": {\n \"type\": \"object\",\n \"required\": [\"queries\"],\n \"properties\": {\n \"queries\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"actor\", \"description\", \"returns\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\", \"description\": \"GetX / ListX / SearchX\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"actor\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" },\n \"input\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"type\"],\n \"properties\": { \"name\": { \"type\": \"string\" }, \"type\": { \"type\": \"string\" }, \"required\": { \"type\": \"boolean\" } }\n }\n },\n \"returns\": { \"type\": \"string\" },\n \"scoping\": { \"type\": \"string\", \"description\": \"tenancy/visibility rule applied (e.g. 'rows owned by caller only')\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"events.json\": {\n \"type\": \"object\",\n \"required\": [\"events\"],\n \"properties\": {\n \"events\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"description\", \"producedBy\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\", \"description\": \"past-tense business fact: InvoicePaid. No technical events (ButtonClicked).\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"producedBy\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" }, \"description\": \"command/workflow names\" },\n \"consumedBy\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"what reacts today (email send, status flip, webhook out)\" },\n \"payloadHints\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"explicit\": { \"type\": \"boolean\", \"description\": \"true if the code emits a real event; false if reconstructed from inline side-effects\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"policies.json\": {\n \"type\": \"object\",\n \"required\": [\"policies\"],\n \"properties\": {\n \"policies\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"rule\", \"type\", \"domain\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"rule\": { \"type\": \"string\", \"description\": \"plain-language statement: 'Only the invoice owner can send it'\" },\n \"type\": { \"type\": \"string\", \"enum\": [\"authorization\", \"validation\", \"state\", \"business-constraint\", \"rate-limit\"] },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedAt\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"every code location enforcing it — multiple locations = divergence risk, note in gaps.json\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"workflows.json\": {\n \"type\": \"object\",\n \"required\": [\"workflows\"],\n \"properties\": {\n \"workflows\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"actor\", \"trigger\", \"steps\", \"result\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"user\", \"admin\", \"system\"] },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\" },\n \"steps\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" } },\n \"result\": { \"type\": \"string\" },\n \"commandsInvolved\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"eventsInvolved\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"schedule\": { \"type\": \"string\", \"description\": \"cron expression for system workflows, if any\" },\n \"scenarios\": {\n \"type\": \"array\",\n \"description\": \"concrete use cases, primarily excavated from the test suite (describe/it names, fixtures, assertions). A scenario sourced from a test is high-confidence by definition.\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"outcome\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"given\": { \"type\": \"string\" },\n \"when\": { \"type\": \"string\" },\n \"outcome\": { \"type\": \"string\" },\n \"fromTest\": { \"type\": \"string\", \"description\": \"test file path + test name, when sourced from a test\" }\n }\n }\n },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"api.json\": {\n \"type\": \"object\",\n \"required\": [\"surfaces\"],\n \"properties\": {\n \"surfaces\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"kind\", \"path\", \"auth\", \"mapsTo\", \"evidence\"],\n \"properties\": {\n \"kind\": { \"type\": \"string\", \"enum\": [\"rest\", \"graphql\", \"rpc\", \"webhook-in\", \"webhook-out\", \"page\", \"cli\", \"websocket\", \"sse\"] },\n \"method\": { \"type\": \"string\" },\n \"path\": { \"type\": \"string\" },\n \"auth\": { \"type\": \"string\", \"description\": \"none | session | jwt | api-key | signature | capability-url | ...\" },\n \"mapsTo\": {\n \"type\": \"object\",\n \"required\": [\"type\", \"name\"],\n \"properties\": {\n \"type\": { \"type\": \"string\", \"enum\": [\"command\", \"query\", \"event-ingress\"], \"description\": \"command/query for normal surfaces. event-ingress ONLY for surfaces that relay an external event without a domain command of their own; its name must be an events.json event. A webhook whose handler changes state maps to a command (usual case).\" },\n \"name\": { \"$ref\": \"#/$defs/conceptName\" }\n }\n },\n \"notes\": { \"type\": \"string\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n }\n }\n },\n \"integrations.json\": {\n \"type\": \"object\",\n \"required\": [\"integrations\"],\n \"properties\": {\n \"integrations\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"category\", \"purpose\", \"direction\", \"importance\", \"replacementDifficulty\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"category\": { \"type\": \"string\", \"description\": \"payments | email | auth | storage | analytics | llm | sms | ...\" },\n \"purpose\": { \"type\": \"string\" },\n \"dataExchanged\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"direction\": { \"type\": \"string\", \"enum\": [\"outbound\", \"inbound\", \"both\"] },\n \"importance\": { \"type\": \"string\", \"enum\": [\"critical\", \"important\", \"peripheral\"] },\n \"replacementDifficulty\": { \"type\": \"string\", \"enum\": [\"trivial\", \"moderate\", \"hard\"] },\n \"configVia\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"env vars / secrets used\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"architecture.json\": {\n \"type\": \"object\",\n \"required\": [\"components\", \"datastores\"],\n \"properties\": {\n \"components\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"responsibility\", \"evidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"frontend\", \"api\", \"worker\", \"job\", \"webhook-handler\", \"cli\", \"service\", \"proxy\", \"other\"] },\n \"responsibility\": { \"type\": \"string\" },\n \"dependsOn\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"runtime\": { \"type\": \"string\", \"description\": \"how it runs today (process, cron, lambda, ...)\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n },\n \"datastores\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"evidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\" },\n \"usedBy\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"notes\": { \"type\": \"string\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n },\n \"notes\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"deployment constraints worth preserving (ports, raw-body routes, ordering)\" }\n }\n },\n \"invariants.json\": {\n \"type\": \"object\",\n \"required\": [\"invariants\"],\n \"properties\": {\n \"invariants\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"statement\", \"domain\", \"enforcedBy\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"statement\": { \"type\": \"string\", \"description\": \"must ALWAYS hold: 'Invoice numbers are sequential per user with no gaps'\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedBy\": { \"type\": \"string\", \"description\": \"db-constraint | code-guard | convention | nothing (!)\" },\n \"atRiskBecause\": { \"type\": \"string\", \"description\": \"why current enforcement is fragile, if it is\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"gaps.json\": {\n \"type\": \"object\",\n \"required\": [\"gaps\"],\n \"properties\": {\n \"gaps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"problem\", \"kind\", \"impact\", \"recommendation\", \"evidence\"],\n \"properties\": {\n \"problem\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"incomplete-feature\", \"todo\", \"duplication\", \"bug\", \"hack\", \"dead-code\", \"unclear-ownership\", \"architecture\", \"security\", \"open-product-decision\"] },\n \"impact\": { \"type\": \"string\" },\n \"recommendation\": { \"type\": \"string\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n }\n }\n },\n \"migration.json\": {\n \"type\": \"object\",\n \"required\": [\"mappings\", \"decisionsNeeded\"],\n \"properties\": {\n \"mappings\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"current\", \"future\", \"recommendation\"],\n \"properties\": {\n \"current\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" }, \"description\": \"file paths in the existing repo\" },\n \"future\": {\n \"type\": \"object\",\n \"required\": [\"domain\"],\n \"properties\": {\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"concepts\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"commands/queries/events/entities this code becomes\" }\n }\n },\n \"recommendation\": { \"type\": \"string\" }\n }\n }\n },\n \"dropped\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"path\", \"reason\"],\n \"properties\": { \"path\": { \"type\": \"string\", \"description\": \"file path; for sub-file drops use 'path (symbolName)' when part of a file survives\" }, \"reason\": { \"type\": \"string\" } }\n }\n },\n \"decisionsNeeded\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"question\"],\n \"properties\": {\n \"question\": { \"type\": \"string\" },\n \"options\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"blockedConcepts\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n }\n }\n }\n }\n },\n \"interfaces.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"Every way the product is CONSUMED, one entry per channel. The web UI is one channel (detailed further in frontend*.json); CLI, MCP server (for AI agents), OpenAPI/REST, generated SDK, realtime, and webhooks are others. Answers 'who can drive this product and how'.\",\n \"required\": [\"interfaces\"],\n \"properties\": {\n \"interfaces\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"audience\", \"purpose\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"web-ui\", \"cli\", \"mcp\", \"openapi-rest\", \"graphql\", \"sdk\", \"websocket-realtime\", \"webhook-in\", \"webhook-out\", \"email\", \"other\"] },\n \"audience\": { \"type\": \"string\", \"enum\": [\"human\", \"developer\", \"ai-agent\", \"external-system\", \"internal\"] },\n \"purpose\": { \"type\": \"string\" },\n \"surfaceCount\": { \"type\": \"number\", \"description\": \"how many ops/tools/commands/routes this channel exposes\" },\n \"generated\": { \"type\": \"boolean\", \"description\": \"true if generated from another source (OpenAPI from routes, typed SDK, MCP tools from funcs) rather than hand-written\" },\n \"domainsServed\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"status\": { \"type\": \"string\", \"enum\": [\"complete\", \"partial\", \"stub\", \"deprecated\"] },\n \"notes\": { \"type\": \"string\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"frontend.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"Web-UI app-level shape. Names the framework/router/styling/data/auth so the rebuild can weigh their tradeoffs (e.g. TanStack Start + better-auth + Mantine).\",\n \"required\": [\"framework\", \"styling\", \"dataLayer\", \"auth\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"framework\": { \"type\": \"string\", \"description\": \"e.g. TanStack Start, Next.js, Remix, Vite SPA\" },\n \"rendering\": { \"type\": \"string\", \"enum\": [\"spa\", \"ssr\", \"ssg\", \"streaming-ssr\", \"mixed\"] },\n \"router\": { \"type\": \"string\", \"description\": \"e.g. TanStack Router (file-based), React Router\" },\n \"styling\": { \"type\": \"string\", \"description\": \"the design system, e.g. Mantine, Tailwind, MUI, CSS modules\" },\n \"designSystemConsistency\": { \"type\": \"string\", \"enum\": [\"single-system\", \"mostly-consistent\", \"mixed\", \"ad-hoc\"], \"description\": \"how uniformly ONE component/theme system is used — the rebuild target is 'everything Mantine', so divergence is porting work\" },\n \"designFindings\": {\n \"type\": \"array\",\n \"description\": \"Concrete broken/inconsistent design patterns observed across the UI. Each is a specific, cited observation of a consistency defect — not a taste opinion. These become the design recommendations in the second-opinion report.\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"pattern\", \"observation\", \"recommendation\", \"evidence\"],\n \"properties\": {\n \"pattern\": {\n \"type\": \"string\",\n \"enum\": [\n \"interaction-inconsistency\",\n \"theming-not-tokenized\",\n \"cross-page-inconsistency\",\n \"component-duplication\",\n \"spacing-typography-scale\",\n \"design-system-bypass\",\n \"accessibility\",\n \"responsive\",\n \"other\"\n ],\n \"description\": \"interaction-inconsistency = same job done different ways (modal here, drawer there; inconsistent confirm dialogs). theming-not-tokenized = hardcoded colors/spacing/fonts instead of theme tokens/variables. cross-page-inconsistency = the same element (button/header/card) styled differently across pages. component-duplication = several near-identical components for one purpose. spacing-typography-scale = ad-hoc magic numbers off any scale. design-system-bypass = raw HTML/CSS where a design-system component exists. accessibility / responsive = a11y or breakpoint defects.\"\n },\n \"observation\": { \"type\": \"string\", \"description\": \"what is inconsistent, with concrete examples (e.g. 'Add-site uses a Drawer, Edit-site uses a Modal for the same task')\" },\n \"impact\": { \"type\": \"string\", \"description\": \"what it does to the user (feels unpolished/confusing) or to maintenance (a color change means hunting every file)\" },\n \"recommendation\": { \"type\": \"string\", \"description\": \"the fix — usually standardize on one pattern, move values to theme tokens, or extract one shared component\" },\n \"severity\": { \"type\": \"string\", \"enum\": [\"minor\", \"worth-fixing\", \"serious\"] },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n },\n \"stateManagement\": { \"type\": \"string\" },\n \"dataLayer\": { \"type\": \"string\", \"description\": \"how the UI talks to the backend: e.g. pikku-react-query, REST fetch helpers, tRPC, GraphQL client\" },\n \"auth\": { \"type\": \"string\", \"description\": \"client auth mechanism: e.g. better-auth, next-auth, custom JWT\" },\n \"buildTool\": { \"type\": \"string\" },\n \"i18n\": { \"type\": \"string\", \"description\": \"internationalization approach, or 'none'\" },\n \"notes\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n },\n \"frontend-routes.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"The page/route tree — what a user can navigate to and do, mapped back to the data (queries/commands) each route uses and the components it renders.\",\n \"required\": [\"routes\"],\n \"properties\": {\n \"routes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"path\", \"purpose\", \"evidence\"],\n \"properties\": {\n \"path\": { \"type\": \"string\", \"description\": \"URL path, e.g. /app/sites/:id\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"page\", \"layout\", \"index\", \"modal-or-drawer\", \"redirect\"] },\n \"purpose\": { \"type\": \"string\", \"description\": \"what the user does/sees here, in product terms\" },\n \"auth\": { \"type\": \"string\", \"description\": \"none | authenticated | admin | role:xyz\" },\n \"dataFrom\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"query/command names (from queries.json/commands.json) this route reads/calls\" },\n \"usesComponents\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"component names from frontend-components.json\" },\n \"userFlows\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"workflows.json (kind=user) names this route participates in\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"frontend-components.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"Component inventory. The load-bearing field is `rebuild`: it separates components that are trivially rebuildable in the target design system (Mantine) from those carrying bespoke logic that must be carefully PORTED (custom charts, complex tables, canvas, drag/drop). That split is the frontend's real migration cost.\",\n \"required\": [\"components\"],\n \"properties\": {\n \"components\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"role\", \"rebuild\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"role\": { \"type\": \"string\", \"enum\": [\"layout\", \"navigation\", \"presentational\", \"feature\", \"form\", \"data-display\", \"chart\", \"table\", \"overlay\", \"provider\", \"other\"] },\n \"purpose\": { \"type\": \"string\" },\n \"reuse\": { \"type\": \"string\", \"enum\": [\"shared\", \"one-off\"], \"description\": \"used across features vs single-use\" },\n \"rebuild\": {\n \"type\": \"string\",\n \"enum\": [\"mantine-standard\", \"mantine-composition\", \"custom-style\", \"custom-logic\"],\n \"description\": \"mantine-standard = maps 1:1 to a Mantine component (trivial); mantine-composition = built from Mantine primitives (straightforward); custom-style = diverges visually from the design system (normalize to Mantine); custom-logic = bespoke behavior (chart/table/canvas/drag/editor) that must be PORTED, not re-skinned\"\n },\n \"customLogic\": { \"type\": \"string\", \"description\": \"REQUIRED when rebuild=custom-logic: what the bespoke behavior is and why a stock component can't replace it\" },\n \"dependencies\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"notable libs it pulls in (charting/table/editor) — replacement considerations for the port\" },\n \"usedBy\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"routes/components that use it\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n }\n }\n}\n", "pikku-software-archaeology/references/pikku-mapping.md": "# How Pikku Consumes a Product Blueprint\n\nThe `.knowledge/` blueprint is designed so each concept maps onto exactly one Pikku primitive. A generator (or an agent following `pikku-feature`) walks the JSON files in this order:\n\n| Blueprint source | Pikku target |\n| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `entities.json` attributes + relationships + constraints | Kysely migrations + generated `DB` types; Zod schemas per entity |\n| `entities.json` states/transitions | a `status` column + transition guards inside the owning commands (or a state-machine helper) |\n| `commands.json` | `pikkuFunc` / `pikkuSessionlessFunc` with `input:` Zod schema built from `input[]`; `preconditions` become guard clauses; name is the camelCased command name (`SendInvoice` → `sendInvoice`) |\n| `queries.json` | `pikkuFunc` reads; `scoping` becomes the mandatory `WHERE` / session filter |\n| `events.json` | EventHub topics (realtime) or queue messages; `consumedBy` become `wireQueueWorker` handlers — implicit events (`explicit: false`) get promoted to real emissions |\n| `policies.json` (authorization) | Pikku `permissions` / middleware; one policy = one named permission function, wired everywhere `enforcedAt` listed — this collapses duplicated legacy checks into a single definition |\n| `policies.json` (validation) | Zod schema refinements on the command's `input` |\n| `workflows.json` kind=user | frontend flows + the commands they chain |\n| `workflows.json` kind=system, with `schedule` | `wireScheduler` entries |\n| `workflows.json` multi-step / checkpointing | `pikkuWorkflowFunc` with one `workflow.do(...)` step per blueprint step |\n| `workflows.json` `scenarios[]` | **`pikkuUserFlow` stories — this is the canonical target.** Each scenario's given/when/outcome maps 1:1 onto a user-flow step sequence; group scenarios by their workflow into one flow per journey. Only scenarios with no user-facing surface (pure system workflows: cron sweeps, webhook ingest) fall back to API/e2e tests |\n| `api.json` | `wireHTTP` routes: keep `path`+`method` for compatibility, point at the mapped command/query func; `auth: none`/capability-URL surfaces get `auth: false` |\n| `api.json` kind=webhook-in | `wireHTTP` with `auth: false` + signature-verification middleware from the integration |\n| `integrations.json` | services in `services.ts` (constructor-injected classes); `configVia` env vars become `defineSecret` / config; per-user credentials become `defineCredential` |\n| `architecture.json` notes | deployment config (ports, raw-body routes, proxy expectations) |\n| `invariants.json` enforcedBy=db-constraint | migration constraints (UNIQUE, CHECK, FK) |\n| `invariants.json` enforcedBy=code-guard/nothing | guard clauses + a test each; `atRiskBecause` entries get a hardening task |\n| `gaps.json` | excluded from generation; `open-product-decision` + `migration.json.decisionsNeeded` go to a human BEFORE generation starts |\n| `migration.json.mappings` | the work plan: one mapping = one migration slice |\n| `interfaces.json` kind=cli | `wireCLI` entrypoints — the CLI commands are the same funcs the routes expose |\n| `interfaces.json` kind=mcp | `wireMCP` — each MCP tool IS a `pikkuFunc` (reuse the command/query funcs; don't author tool duplicates) |\n| `interfaces.json` kind=openapi-rest / sdk | generated, not hand-written: the OpenAPI spec + typed client SDK fall out of the `wireHTTP` routes + codegen |\n| `interfaces.json` kind=websocket-realtime | `pikku-realtime` EventHub topics / channels |\n| `frontend.json` | `apps/app` (TanStack Start) shell: router, `@pikku/mantine` theme, `pikku-react-query` data layer, `better-auth` client — the target stack the legacy UI is rebuilt onto |\n| `frontend-routes.json` | TanStack Router routes under `apps/app/src/routes/**` (thin data containers calling `usePikkuQuery`); `dataFrom` names become the generated hooks; subpath routes for rich detail views |\n| `frontend-components.json` rebuild=`mantine-standard`/`mantine-composition` | components in `packages/components` composed from `@pikku/mantine` — the trivial/straightforward bulk |\n| `frontend-components.json` rebuild=`custom-logic` | the PORT list — each becomes a `packages/components` component that reimplements the bespoke behavior (chart/table/editor); its `dependencies` inform whether the lib is kept or replaced. These are the frontend's real work items |\n| `frontend-components.json` rebuild=`custom-style` | normalize to Mantine/theme tokens; usually deleted-and-recomposed, not ported |\n\n## Order of generation\n\n1. Human resolves `decisionsNeeded`.\n2. Entities → migrations + types.\n3. Policies → permission functions (before commands, so commands can reference them).\n4. Commands + queries → funcs; api.json → wirings.\n5. Events → topics/queues; system workflows → schedulers/workers/workflows.\n6. Scenarios → tests. Run them against the new implementation; they encode the legacy behavior worth preserving.\n\n## Uncertainty handling\n\n- `confidence: high` concepts generate directly.\n- `confidence: medium` concepts generate, but are listed for review in the pre-generation report — id, evidence summary, and what is uncertain — rather than carrying a marker comment in the generated code. The report is the review surface; the generated code stays clean.\n- `confidence: low` concepts are NOT generated automatically — they surface in the pre-generation review along with `decisionsNeeded`.\n", "pikku-software-archaeology/scripts/validate.mjs": "#!/usr/bin/env node\n// Validates a .knowledge/ blueprint directory against references/blueprint.schema.json,\n// then runs cross-file referential checks (does every command's domain exist, does every\n// api surface map to a real command/query, ...). Exit 0 = valid, 1 = errors.\n//\n// Usage: node validate.mjs <path-to-.knowledge-dir>\n\nimport { readFileSync, existsSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst schemaDoc = JSON.parse(readFileSync(join(here, '..', 'references', 'blueprint.schema.json'), 'utf8'));\n\nconst dir = process.argv[2];\nif (!dir) { console.error('usage: node validate.mjs <.knowledge dir>'); process.exit(2); }\n\nconst errors = [];\nconst warnings = [];\n\n// --- minimal JSON-Schema-subset validator (type, required, properties, items, enum, minItems, pattern, $ref -> $defs) ---\nfunction resolveRef(ref) {\n const m = /^#\\/\\$defs\\/(\\w+)$/.exec(ref);\n if (!m || !schemaDoc.$defs[m[1]]) throw new Error(`unresolvable $ref ${ref}`);\n return schemaDoc.$defs[m[1]];\n}\n\nfunction check(value, schema, path) {\n if (schema.$ref) schema = { ...resolveRef(schema.$ref), ...schema, $ref: undefined };\n if (schema.enum && !schema.enum.includes(value)) {\n errors.push(`${path}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}`);\n return;\n }\n const t = schema.type;\n if (t === 'object') {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n errors.push(`${path}: expected object`); return;\n }\n for (const req of schema.required || []) {\n if (!(req in value)) errors.push(`${path}: missing required field \"${req}\"`);\n }\n for (const [k, v] of Object.entries(value)) {\n if (schema.properties?.[k]) check(v, schema.properties[k], `${path}.${k}`);\n }\n } else if (t === 'array') {\n if (!Array.isArray(value)) { errors.push(`${path}: expected array`); return; }\n if (schema.minItems && value.length < schema.minItems) {\n errors.push(`${path}: needs at least ${schema.minItems} item(s), has ${value.length}`);\n }\n if (schema.items) value.forEach((v, i) => check(v, schema.items, `${path}[${i}]`));\n } else if (t === 'string') {\n if (typeof value !== 'string') { errors.push(`${path}: expected string`); return; }\n if (schema.pattern && !new RegExp(schema.pattern).test(value)) {\n errors.push(`${path}: \"${value}\" does not match ${schema.pattern}`);\n }\n } else if (t === 'boolean' && typeof value !== 'boolean') {\n errors.push(`${path}: expected boolean`);\n } else if (t === 'number' && typeof value !== 'number') {\n errors.push(`${path}: expected number`);\n }\n}\n\n// --- load + per-file validation ---\n// Files marked `x-optional` (the frontend layer) only validate when present, so a\n// backend-only repo does not fail for lacking them.\nconst docs = {};\nfor (const [filename, fileSchema] of Object.entries(schemaDoc.files)) {\n const p = join(dir, filename);\n if (!existsSync(p)) {\n if (!fileSchema['x-optional']) errors.push(`${filename}: missing`);\n continue;\n }\n try {\n docs[filename] = JSON.parse(readFileSync(p, 'utf8'));\n } catch (e) {\n errors.push(`${filename}: invalid JSON (${e.message})`); continue;\n }\n check(docs[filename], fileSchema, filename);\n}\nif (!existsSync(join(dir, 'blueprint.md'))) errors.push('blueprint.md: missing');\n\n// --- cross-file referential checks ---\nif (docs['domains.json'] && docs['commands.json']) {\n const domains = new Set((docs['domains.json'].domains || []).map((d) => d.name));\n const commandNames = new Set((docs['commands.json'].commands || []).map((c) => c.name));\n const queryNames = new Set((docs['queries.json']?.queries || []).map((q) => q.name));\n const eventNames = new Set((docs['events.json']?.events || []).map((e) => e.name));\n\n const wantDomain = (owner, d) => {\n if (d && !domains.has(d)) errors.push(`${owner}: domain \"${d}\" not defined in domains.json`);\n };\n for (const c of docs['commands.json'].commands || []) {\n wantDomain(`commands.json:${c.name}`, c.domain);\n for (const ev of c.eventsProduced || []) {\n if (!eventNames.has(ev)) warnings.push(`commands.json:${c.name} produces \"${ev}\" which is not in events.json`);\n }\n }\n for (const q of docs['queries.json']?.queries || []) wantDomain(`queries.json:${q.name}`, q.domain);\n for (const e of docs['entities.json']?.entities || []) wantDomain(`entities.json:${e.name}`, e.domain);\n for (const ev of docs['events.json']?.events || []) wantDomain(`events.json:${ev.name}`, ev.domain);\n\n for (const s of docs['api.json']?.surfaces || []) {\n const { type, name } = s.mapsTo || {};\n if (type === 'command' && !commandNames.has(name)) errors.push(`api.json:${s.method || ''} ${s.path}: maps to unknown command \"${name}\"`);\n if (type === 'query' && !queryNames.has(name)) errors.push(`api.json:${s.method || ''} ${s.path}: maps to unknown query \"${name}\"`);\n if (type === 'event-ingress' && !eventNames.has(name)) errors.push(`api.json:${s.method || ''} ${s.path}: event-ingress maps to unknown event \"${name}\" (state-changing webhooks should map to a command instead)`);\n }\n // every domain's listed concepts should exist\n for (const d of docs['domains.json'].domains || []) {\n for (const c of d.commands || []) if (!commandNames.has(c)) warnings.push(`domains.json:${d.name}: lists command \"${c}\" not in commands.json`);\n for (const q of d.queries || []) if (!queryNames.has(q)) warnings.push(`domains.json:${d.name}: lists query \"${q}\" not in queries.json`);\n for (const e of d.events || []) if (!eventNames.has(e)) warnings.push(`domains.json:${d.name}: lists event \"${e}\" not in events.json`);\n const policyNames = new Set((docs['policies.json']?.policies || []).map((p) => p.name));\n for (const p of d.policies || []) if (!policyNames.has(p)) warnings.push(`domains.json:${d.name}: lists policy \"${p}\" not in policies.json`);\n }\n // commands with no policies and no preconditions are suspicious for mutating ops\n for (const c of docs['commands.json'].commands || []) {\n if (!(c.policies || []).length && !(c.preconditions || []).length) {\n warnings.push(`commands.json:${c.name}: no policies or preconditions — really unguarded, or missed extraction?`);\n }\n }\n}\n\n// --- frontend layer cross-checks (only when the optional frontend files exist) ---\nif (docs['frontend-components.json']) {\n const componentNames = new Set(\n (docs['frontend-components.json'].components || []).map((c) => c.name),\n );\n // routes should reference components that were actually inventoried\n for (const r of docs['frontend-routes.json']?.routes || []) {\n for (const c of r.usesComponents || []) {\n if (!componentNames.has(c)) {\n warnings.push(`frontend-routes.json:${r.path}: uses component \"${c}\" not in frontend-components.json`);\n }\n }\n }\n // a component flagged as needing a port must say WHY (the custom logic), or the\n // port-risk is unactionable\n for (const c of docs['frontend-components.json'].components || []) {\n if (c.rebuild === 'custom-logic' && !c.customLogic) {\n warnings.push(`frontend-components.json:${c.name}: rebuild=custom-logic but no customLogic description — port risk is unactionable`);\n }\n }\n // data-fetching queries named on routes should resolve to a real query/command\n if (docs['queries.json'] || docs['commands.json']) {\n const known = new Set([\n ...(docs['queries.json']?.queries || []).map((q) => q.name),\n ...(docs['commands.json']?.commands || []).map((c) => c.name),\n ]);\n for (const r of docs['frontend-routes.json']?.routes || []) {\n for (const d of r.dataFrom || []) {\n if (!known.has(d)) {\n warnings.push(`frontend-routes.json:${r.path}: reads \"${d}\" which is not a known query/command`);\n }\n }\n }\n }\n}\n\n// an inconsistent UI with no specific design findings = under-extraction\n// (guarded on frontend.json alone — independent of the component inventory)\nif (docs['frontend.json']) {\n const consistency = docs['frontend.json'].designSystemConsistency;\n const findingCount = (docs['frontend.json'].designFindings || []).length;\n if ((consistency === 'mixed' || consistency === 'ad-hoc') && findingCount === 0) {\n warnings.push(`frontend.json: designSystemConsistency=\"${consistency}\" but designFindings is empty — name the specific broken patterns (interaction/theming/cross-page/…)`);\n }\n}\n\nfor (const w of warnings) console.log(`WARN ${w}`);\nfor (const e of errors) console.log(`ERROR ${e}`);\nconsole.log(`\\n${errors.length} error(s), ${warnings.length} warning(s) across ${Object.keys(docs).length} files`);\nprocess.exit(errors.length ? 1 : 0);\n", "pikku-software-archaeology/SKILL.md": "---\nname: pikku-software-archaeology\ndescription: 'Use when reverse-engineering an existing repository into a Product Blueprint — recovering what product an undocumented or organically-grown codebase implements so it can be rebuilt cleanly (e.g. as a Pikku app). TRIGGER when: user says \"extract a blueprint\", \"reverse engineer this app\", \"what does this codebase actually do as a product\", \"prepare this repo for a rewrite/migration\", or points at a legacy repo (any language — JS, TS, Ruby, Python, PHP, Go) and asks for its domains, workflows, business rules, or a rebuild plan. DO NOT TRIGGER for: documenting code structure, generating API docs from an already-clean codebase, or code review.'\ninstallGroups: [fabric]\n---\n\n# Software Archaeology\n\n## Overview\n\nExtract **intent over implementation**. A repository is a fossil record of product decisions; your job is to recover the product — domains, entities, commands, queries, events, policies, workflows, invariants — not to describe the code. The output is a `.knowledge/` directory of schema-validated JSON plus a human-readable `blueprint.md`, consumable by a generator (Pikku) to rebuild the application cleanly.\n\n**You are the parser.** Do not build or rely on regex/AST scanners — read the code with your own tools (Grep, Read, subagents). This is what makes the skill language-agnostic: an Express app, a Rails app, and a Django app all yield the same blueprint shape.\n\n**Two layers, never merged silently:**\n- **Facts** — behavior directly observed in code, schema, or tests. Cite them.\n- **Inferred intent** — the product reasoning you reconstruct. Mark it with `confidence` and say what evidence it rests on.\n\nNever present a guess as a fact. `confidence: \"high\"` requires file:line evidence of the behavior itself.\n\n## Output Contract\n\nEverything goes in `<repo>/.knowledge/` (or a caller-specified directory):\n\n```\n.knowledge/\n├── product.json # purpose, actors, capabilities, terminology\n├── domains.json # business domains (NEVER folder names)\n├── entities.json # domain entities: attributes, relationships, states, transitions\n├── commands.json # state-changing actions (SendInvoice, not POST /invoices/:id/send)\n├── queries.json # read operations and views\n├── events.json # business facts, past tense (InvoicePaid) — no technical events\n├── policies.json # authorization, validation, state, business-constraint rules\n├── workflows.json # user + admin + system workflows, with test-derived scenarios\n├── api.json # every surface, each mapped to a command/query/event-ingress\n├── integrations.json # external services: purpose, direction, replaceability\n├── architecture.json # components, datastores, deployment constraints worth keeping\n├── invariants.json # what must ALWAYS be true, and what enforces it today\n├── gaps.json # TODOs, hacks, duplication, dead code, open product decisions\n├── migration.json # current files -> future concepts, drops, decisions needed\n├── blueprint.md # human synthesis of all of the above\n│\n│ # OPTIONAL — the consumer-surface layer. Emit these when the repo has a\n│ # frontend and/or non-HTTP consumer channels. A backend-only repo omits them\n│ # and the validator does not complain.\n├── interfaces.json # every way the product is consumed: web-ui, cli, mcp, openapi-rest, sdk, realtime, webhooks\n├── frontend.json # web-UI app shape: framework, router, styling, data layer, auth (e.g. TanStack Start + better-auth + Mantine)\n├── frontend-routes.json # the page/route tree — what a user navigates to, its data + components\n└── frontend-components.json# component inventory; `rebuild` splits trivial-Mantine from custom-logic-to-port\n```\n\nThe exact field shapes live in `references/blueprint.schema.json` (in this skill's directory — read it before writing any output file). After writing, ALWAYS run:\n\n```bash\nnode <skill-dir>/scripts/validate.mjs <repo>/.knowledge\n```\n\nand fix every ERROR (WARNs are prompts to double-check, not necessarily wrong). Do not declare the extraction done with validation errors outstanding.\n\n## The Pipeline\n\nWork in phases. For small repos (< ~50 source files) do them inline; for larger repos, fan out subagents per phase-3 lens and merge (see \"Scaling up\").\n\n### Phase 1 — Survey (facts only)\n\nBuild an inventory before interpreting anything:\n- Manifests (`package.json`, `Gemfile`, `pyproject.toml`, `go.mod`, `composer.json`): dependencies are integration hints; scripts are entry points.\n- Entry points: servers, route registrations, cron/scheduler setup, queue workers, CLI binaries.\n- Data layer: migrations, schema files, model classes, raw DDL (check comments too — schemas hide in comments in migration-less repos).\n- Every HTTP/GraphQL/RPC surface, webhook, scheduled job, queue consumer.\n- **All consumer channels, not just HTTP**: a frontend app (`apps/`, `frontend/`, `web/`, `client/`), a CLI (`bin/`, `wireCLI`, a `cli/` dir, an `openapi`-generated command tool), an MCP server (`wireMCP`, `@modelcontextprotocol`, a `mcp`/`tools` dir), an OpenAPI/Swagger spec (`openapi.json`, `swagger`), a published/generated SDK, realtime channels (websocket/SSE). Each is an `interfaces.json` entry.\n- Env vars and config files.\n- TODO / FIXME / HACK / XXX / deprecated markers — each is a gaps.json candidate.\n- **The test suite** — locate it now, excavate it in Phase 2.\n\nWhere intent hides, per ecosystem (read these first):\n\n| Ecosystem | Highest-yield locations |\n|---|---|\n| Rails | `config/routes.rb`, model validations + callbacks + `aasm`/state machines, `app/policies` (Pundit) / `ability.rb` (CanCan), Sidekiq/ActiveJob workers, `db/schema.rb`, specs (esp. request + model specs) |\n| Express/Node | route registration files, middleware chains (auth!), inline `if` guards in handlers, SQL/ORM models, `jobs/`+crontab refs, webhook handlers |\n| Django | `urls.py`, model `Meta`/constraints/`clean()`, DRF serializers + permissions classes, celery tasks, admin.py (reveals internal workflows) |\n| Laravel | `routes/`, FormRequests (validation), Policies/Gates, Jobs + scheduler in `Kernel.php`, migrations |\n| Go | mux/router setup, middleware, struct tags, `cmd/` binaries (each is a component) |\n| Frontend (React/Vue/etc.) | router config / file-based routes (pages a user reaches), the component tree, the design-system import (`@mantine/*`, `@mui/*`, Tailwind config) to judge consistency, the data layer (react-query/tRPC/fetch wrappers) to tie UI back to backend queries, charts/tables/editors (the `custom-logic` port risk), auth wiring |\n\n### Phase 2 — Test excavation (do not skip)\n\nTests are the closest thing to an executable product spec. For every test file:\n- `describe`/`context`/`it` names → **scenarios** (attach to the matching workflow in `workflows.json` under `scenarios[]`, with `fromTest` set).\n- User-flow/journey harnesses (`pikkuUserFlow` stories, cucumber `.feature` files, Playwright journeys) are the highest-grade scenario source — they already ARE given/when/outcome sequences; extract them verbatim.\n- Assertions → confirmations of policies and invariants (upgrade their `confidence` to `high`, add the test as evidence).\n- Fixtures/factories → entity attribute shapes and realistic example data.\n- Edge-case tests → business rules that exist **nowhere else in the code** (e.g. \"replayed webhook events are idempotent\" may only be stated in a test).\n- Untested-but-critical paths, or a test suite that can't run (missing helpers, broken setup) → `gaps.json`.\n\nA rule attested by both an implementation guard AND a test is your strongest possible evidence — cite both.\n\n### Phase 3 — Extraction lenses\n\nRun each lens over the surveyed material. Rules that counter the classic failure modes:\n\n**Domains** — infer from data ownership, workflows, and vocabulary; NEVER from folder names. `controllers/` is not a domain; \"Billing\" is. A domain owns entities and the commands that mutate them.\n\n**Entities** — domain concepts, not tables. Include: attributes (from schema + serializers + fixtures), relationships, **lifecycle states and transitions** (grep status/state columns, then find every write to them — each write site is a transition with a trigger), ownership (which actor's rows), constraints.\n\n**Commands** — every way state changes: routes, jobs, webhooks, CLI, admin consoles, DB triggers. Name them imperative `VerbNoun` in domain language: `POST /api/users/:id/status` → `ActivateUser`. For each: actor, preconditions (every `if (...) return 4xx` guard is a precondition or policy), effects, events produced. Convention: authentication/token issuance is a command (`LogInUser`, effect: \"issues a session/JWT\") even though it writes no rows — it changes the caller's security state.\n\n**Queries** — reads and views, `GetX`/`ListX`/`SearchX`. Record the tenancy scoping each applies (a missing `WHERE user_id=` that exists elsewhere is a gaps.json security entry).\n\n**Events** — meaningful business facts, past tense. Most legacy apps have **implicit** events: an email send, a status flip, and a counter bump inside one handler are the event's consumers — reconstruct `InvoicePaid` from them and set `explicit: false`. Exclude technical noise (ButtonClicked, FunctionCalled). Inclusion threshold for implicit events: at least one observed consumer beyond the row write itself (an email, a downstream job, a webhook out, a derived-state flip). Plain CRUD facts with no reaction (`ClientCreated` that nothing listens to) do not become events.\n\n**Policies** — authorization, validation, state rules, business constraints. Record **every** location enforcing each rule in `enforcedAt`; the same rule enforced in 3 places (or worse, 2 slightly different versions) is a gaps.json duplication entry.\n\n**Workflows** — ALL of them: user journeys, admin/support operations, and **system workflows** (cron jobs, queue consumers, webhook reactions, syncs, notification sweeps). A crontab line in a comment is a workflow. Attach Phase-2 scenarios.\n\n**API** — list every surface but map each to its concept (`mapsTo: {type: command, name: SendInvoice}`). The route is evidence; the command is the deliverable. Auth per surface (including \"none\" and capability-URLs like tokened public links). A webhook whose handler changes state maps to a **command** (`RecordInvoicePayment`); reserve `event-ingress` for pure relay surfaces, where `name` must be an events.json event.\n\n**Integrations** — from deps + config + calls: purpose, data exchanged, direction, importance, replacement difficulty, env vars.\n\n**Architecture** — components as they actually run (API process, worker, cron job, SPA), datastores, and deployment constraints that must survive the rewrite (hardcoded ports with upstream expectations, raw-body middleware ordering, webhook retry semantics).\n\n**Invariants** — what must always hold, and `enforcedBy`: db-constraint, code-guard, convention, or `nothing` (an unenforced invariant is a gap). Include `atRiskBecause` when enforcement is fragile (e.g. read-then-insert sequence numbering races).\n\n**Gaps** — incomplete features, TODOs, hacks (hardcoded admin emails), duplicated logic that drifted, dead code, unclear ownership, and **open product decisions** the code never resolved (a FIXME asking \"should deleting a client void invoices?\" is a product decision, record it in both gaps.json and migration.json `decisionsNeeded`).\n\n**Migration** — map current file clusters → future domain + concepts; list files to drop with reasons; list decisions a human must make before rebuild.\n\n**Interfaces** (`interfaces.json`, optional) — every way the product is CONSUMED, one entry per channel, not per route. A product is usually driven through several: a **web UI** (humans), a **CLI** (developers/operators), an **MCP server** (AI agents — in a Pikku app each MCP tool IS a `pikkuFunc`), an **OpenAPI/REST** surface (developers/external systems, often *generated* from the routes), a **generated SDK**, **realtime** (websocket/SSE), and **webhooks** (in/out). For each: `kind`, `audience`, `purpose`, roughly how many ops it exposes, whether it's `generated` vs hand-written, which domains it serves, and `status` (complete/partial/stub — an MCP server with two tools is `stub`). This layer answers \"who can drive this, and how\" — it is the map the second-opinion skill needs to explain that the app is usable by people, developers, and agents.\n\n**Frontend** (`frontend.json` + `frontend-routes.json` + `frontend-components.json`, optional) — the web UI, which needs its own treatment because frontends vary wildly (framework, router, styling, state, data, auth) and the rebuild target is opinionated: **everything in one component system (Mantine), one data layer, one auth**.\n- `frontend.json` records the stack as FACTS: framework (e.g. TanStack Start), rendering (SSR/streaming/SPA), router, styling/design system, `designSystemConsistency`, state management, data layer (e.g. pikku-react-query vs REST helpers), auth (e.g. better-auth), i18n. Name the real technologies — the second-opinion skill weighs their tradeoffs, so record them precisely (do NOT editorialize here; this file is facts).\n- `frontend-routes.json` is the page tree: each route's `purpose` in product terms, `auth`, the `dataFrom` (query/command names it reads — reuse the backend concept names so the UI ties back to the domain), the `usesComponents`, and the `userFlows` it belongs to.\n- `frontend.json.designFindings` captures **broken/inconsistent design patterns** as concrete, cited observations (not taste). Actively hunt for: *interaction inconsistency* (the same job done as a modal in one place and a drawer in another; inconsistent confirm dialogs); *theming not tokenized* (hardcoded hex colors, magic spacing/font sizes, inline styles instead of theme tokens/variables — grep for `#[0-9a-f]{3,6}`, `style={{`, raw `px` values); *cross-page inconsistency* (the same element — button, page header, card — styled differently across routes); *component duplication* (three near-identical cards/tables for one purpose); *design-system bypass* (raw HTML/CSS where a library component exists). Each finding gets an example, its impact (feels unpolished / a color change means hunting every file), and a fix (standardize on one pattern / move to tokens / extract one shared component). These are almost always cheap cleanups, and they are exactly what a non-technical owner perceives as \"the app looks off\" without being able to say why.\n- `frontend-components.json` is where the frontend's real migration cost lives, in the **`rebuild`** field: `mantine-standard` (maps 1:1 to a Mantine component — trivial), `mantine-composition` (built from Mantine primitives — straightforward), `custom-style` (diverges only visually — normalize to Mantine), or **`custom-logic`** (bespoke behavior — a custom chart, a virtualized/complex table, a canvas, drag-and-drop, a rich editor — that must be **ported**, not re-skinned). A `custom-logic` component MUST fill `customLogic` explaining the behavior, and should list the `dependencies` (charting/table/editor libs) that make it a real port. This split — \"trivially re-Mantine-able\" vs \"carries logic that must survive the port\" — is the single most useful thing the frontend extraction produces.\n- **Server-rendered / non-React frontends still get all three files — do NOT skip them.** The `rebuild` enum is named for the target stack, but the distinction it draws is target-agnostic: *trivial stock element* vs *composed from primitives* vs *visual divergence only* vs **bespoke behavior that must be ported**. A Rails app (Slim/ERB + ViewComponents + Hotwire/Stimulus), a Django app (templates + HTMX), or a Laravel app (Blade + Livewire) all have that same split, and it is just as load-bearing there. Use the enum values verbatim (the validator enforces them), classify by what the thing actually *does*, and record the vocabulary mismatch in `frontend.json.notes`. Concretely: treat a template partial + its behavior controller (Stimulus/Alpine/Livewire) as ONE component and classify the pair; a server-rendered app's `custom-logic` is the same list as a SPA's (maps, charts, video players, payment elements, drag-to-reorder, rich text, QR, live-updating regions), plus anything whose behavior rides on a streaming/partial-update contract (Turbo Streams, HTMX swaps) — get that mapping wrong on rebuild and pages show stale data. Omitting these files because \"it isn't a React app\" hides the frontend's entire migration cost, which is the one thing this file exists to expose.\n- The `designFindings` grep hints above are React-flavored; the server-rendered equivalents are inline `style=` attributes in templates, hardcoded hex in the stylesheet tree, per-locale forked templates instead of i18n, and design-system bypass = raw markup where a component/partial already exists. Same findings, different needles.\n\n### Phase 4 — Cross-check, validate, synthesize\n\n1. Cross-checks before writing: every command has an actor and at least one precondition or policy (or you explain why it is genuinely unguarded); every state transition appears in some command/workflow — if a state is only reachable by manual/DB intervention, record the transition with trigger `\"none (manual/DB-only)\"` AND add a gaps.json entry; every event has a producer; every api surface maps to a defined concept; every integration is used by some command/workflow.\n2. Write all 14 JSON files, sorting **unordered identity collections** by `name` (or `path`) — on first extraction too, not just re-runs. Arrays whose order carries meaning (`workflows[].steps`, and any other observed sequence) stay in their observed order: sorting them would rewrite the behavior you extracted. Run the validator: done means `0 error(s)` and exit 0, and every WARN explicitly reviewed and either fixed or justified in your summary.\n3. Write `blueprint.md`: product summary → domain map → per-domain narrative (entities/commands/events with the interesting rules) → workflows → integrations/architecture → invariants → gaps and open decisions → rebuild recommendation. Write it for the engineer who will rebuild the product and has never seen the legacy code.\n\n## Evidence & Confidence Discipline\n\n- Every concept object carries `evidence: [{file, lines, note}]` and `confidence`. (In `api.json` and `architecture.json`, `confidence` is optional — include it whenever a surface/component is inferred rather than directly observed, e.g. an SPA known only from comments.)\n- `high` — the behavior itself is in the cited code/schema/test.\n- `medium` — inferred from multiple converging signals (naming + partial code + a test name).\n- `low` — plausible reconstruction; MUST also be phrased tentatively in blueprint.md and usually deserves a `decisionsNeeded` entry.\n- Comments and docs describe *intended* behavior; code describes *actual* behavior. When they disagree, record the code's behavior as the fact and the disagreement as a gap.\n\n## Scaling up (large repos)\n\n- Fan out one subagent per lens (or per candidate domain) with: the survey notes, the schema file path, and instructions to return JSON fragments with evidence. Merge, dedupe by concept name, then run Phase 4 yourself.\n- Fix the domain cut YOURSELF after the survey and hand every lens agent the same canonical domain list — domains are the shared IDs everything cross-references.\n- Give each lens agent ownership of whole files (never two agents writing one file), and pair coupled files under one agent (commands+queries+api must share names).\n- **Budget a reconciliation pass — parallel agents WILL drift on names** (observed at real scale: 472 validator warnings from one 7-agent run). The pattern: rebuild `domains.json`'s entity/command/query/event/policy roll-up lists LAST, generated by grouping the authoritative files by their `domain` field — never hand-written before those files exist; reconcile `eventsProduced` against curated `events.json` by rename (spelling variant) / drop (CRUD noise, no consumer) / add (only with verified consumer evidence); fill empty command policies by joining `api.json`'s per-surface `auth` against `policies.json` names. The validator's warning list is the reconciliation worklist.\n- **Incremental re-runs:** keep concept names stable (they are the IDs). Re-run the affected lens only, diff against the existing `.knowledge/` files, and preserve unrelated entries verbatim. Sort every array by `name` (or `path`) so diffs are meaningful.\n\n## Red Flags — you are about to produce a worthless blueprint\n\n| Thought | Reality |\n|---|---|\n| \"I'll write it up as markdown docs\" | Only `blueprint.md` is prose. The 14 JSON files ARE the deliverable; a generator consumes them. |\n| \"The routes are the API contract\" | Routes are evidence. Lift each to a command/query or you've documented plumbing, not product. |\n| \"This is obvious, no citation needed\" | Uncited claims are indistinguishable from hallucinations. Evidence on everything. |\n| \"The folder structure tells me the domains\" | Folders are how it grew, not what it is. Derive domains from ownership + vocabulary. |\n| \"Tests are just tests, skip them\" | Tests are the spec. Some rules exist ONLY in tests. Phase 2 is mandatory. |\n| \"No events are emitted, so events: []\" | Reconstruct implicit events from side-effect clusters; mark `explicit: false`. |\n| \"Cron jobs aren't workflows\" | System workflows are workflows. Include schedules, queue consumers, webhook reactions. |\n| \"The frontend is just the web routes\" | The web UI is ONE interface. Inventory the CLI, MCP server, OpenAPI/SDK, realtime, webhooks in `interfaces.json` too — the product is driven by people, developers, AND agents. |\n| \"A component list is enough\" | Without the `rebuild` split, you've hidden the frontend's real cost. Flag every `custom-logic` component (chart/table/canvas/editor) and say what the logic is — that's the port work. |\n| \"I'll skip the validator, the JSON looks right\" | Run it. Missing domains refs, dangling event names, and undescribed custom-logic components are exactly what it catches. |\n\n## Quick Reference\n\n```bash\n# 1. survey + excavate + extract (you, with Read/Grep/subagents)\n# 2. write <repo>/.knowledge/*.json + blueprint.md per references/blueprint.schema.json\n# 3. validate:\nnode <skill-dir>/scripts/validate.mjs <repo>/.knowledge\n```\n\n- Schema/contract: `references/blueprint.schema.json`\n- How Pikku consumes the blueprint: `references/pikku-mapping.md`\n", "pikku-tag-middleware/SKILL.md": "---\nname: pikku-tag-middleware\ndescription: 'Deprecated — use pikku-middleware instead. Tag middleware (addTagMiddleware) is now documented as a section within the pikku-middleware skill, alongside global HTTP middleware, execution order, and the service-to-service bearer auth pattern.'\n---\n\n# Deprecated: use `pikku-middleware`\n\nTag middleware is covered in the **`pikku-middleware`** skill, which also covers:\n- `addHTTPMiddleware` (global / prefix-based)\n- `addTagMiddleware` (tag-scoped)\n- Middleware execution order and priority\n- Service-to-service bearer auth pattern\n- Session-setting middleware pattern\n", "pikku-template-clone/SKILL.md": "---\nname: pikku-template-clone\ndescription: 'Standard cleanup to run right after a Pikku template is cloned or scaffolded into a new project. TRIGGER when: a Pikku template was just cloned/scaffolded (via `npm create pikku`, `git clone <template>`, or the user says \"I cloned the kanban template / starter / template\"), or the working tree still looks like an untouched template (template README, placeholder `@project/*` name in package.json). DO NOT TRIGGER when: working in an established project mid-feature, or editing the template repo itself.'\nallowed-tools: Bash(git status *), Bash(git add *), Bash(git commit *), Bash(git rm *), Bash(git mv *), Bash(git log *)\ninstallGroups: [core]\n---\n\n# Pikku Template Post-Clone Cleanup\n\n## Agent Operating Procedure\n\nRun this **once**, right after a template is cloned or scaffolded into a new\nproject. The goal is to turn template scaffolding into a real project. Make the\nsmallest changes and land them as one focused `chore: post-clone cleanup`\ncommit, separate from any feature work.\n\n1. **Replace the template README.** The shipped `README.md` describes the\n _template_, not the user's project — leaving it in place is misleading.\n Either delete it (`git rm README.md`) or rewrite it with the new project's\n name and purpose. Never ship a clone with the generic template README.\n2. **Keep the lockfile committed.** Do NOT re-add `yarn.lock` to `.gitignore`.\n A real project commits its lockfile for reproducible installs. The correct\n pattern is `yarn.lock` followed by `!/yarn.lock`, which commits the root\n lockfile while keeping generated per-unit lockfiles under `.deploy/` (and\n `e2e/`) ignored.\n\n `create-pikku` keeps only the chosen package manager's lockfile and deletes\n the other, and for yarn it may have written an **empty** `yarn.lock` as a\n marker. Commit the lockfile *after* the first install has filled it in —\n committing the empty placeholder pins nothing.\n3. **Rename template identifiers.** Update `name` in the root `package.json`\n (and any `@project/*` or other placeholder names) to the real project.\n4. **Drop template-only artifacts.** Remove any `TEMPLATE.md`, demo docs, or\n placeholder content that only made sense for the template.\n\nDo not touch generated files (`.pikku/`, `*.gen.*`) or run a full reinstall as\npart of cleanup — this step is project hygiene, not a build.\n\n## Why this exists\n\nTemplates are structure-only starting points. Without this pass, clones carry a\nmisleading README, a placeholder package name, and (historically) a gitignored\nlockfile — all of which leak template assumptions into a real project. Running\nit immediately after clone keeps every Pikku project, OSS or Fabric, starting\nfrom a clean, honest baseline.\n", "pikku-trigger/SKILL.md": "---\nname: pikku-trigger\ndescription: >-\n Use when adding event-driven functions that respond to system events like Redis pub/sub,\n PostgreSQL LISTEN/NOTIFY, or custom event sources. Covers wireTrigger, wireTriggerSource, and\n pikkuTriggerFunc. TRIGGER when: code uses wireTrigger/wireTriggerSource/pikkuTriggerFunc, user\n asks about event-driven functions, Redis pub/sub, PostgreSQL LISTEN/NOTIFY, or reacting to\n external events. DO NOT TRIGGER when: user asks about scheduled tasks (use pikku-cron) or\n background job queues (use pikku-queue).\ninstallGroups: [core]\n---\n\n# Pikku Trigger Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to fire when external events occur. Triggers connect event sources (Redis pub/sub, PostgreSQL LISTEN/NOTIFY, polling, webhooks) to Pikku functions.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\nAll three come from `#pikku`. A trigger is deliberately split in two: the\n**source** owns the connection to the outside world and the **trigger** names the\nfunction to run, so one source can be swapped (Redis → PG) without touching the\nhandler, and a handler can exist before any source is wired.\n\n### `wireTrigger(config)`\n\nDefine the target function that handles trigger events:\n\n```typescript\nimport { wireTrigger } from '#pikku'\n\nwireTrigger({\n name: string, // Trigger name (matches source)\n func: PikkuFunc, // Function to call when event fires\n description?: string,\n tags?: string[],\n})\n```\n\n### `wireTriggerSource(config)`\n\nDefine the event source that fires triggers:\n\n```typescript\nimport { wireTriggerSource } from '#pikku'\n\nwireTriggerSource({\n name: string, // Must match a wireTrigger name\n func: PikkuTriggerFunc, // Source function (sets up the listener)\n input: object, // Configuration handed to the source\n})\n```\n\n`input` is required whenever the source function declares an input type, and the\nname must be unique — wiring the same source name twice throws\n`Trigger source already exists`.\n\n### `pikkuTriggerFunc<TInput, TEvent>`\n\nA trigger source function runs **once at startup**, not once per event. It sets\nup a listener, calls `trigger.invoke(...)` for each event it sees, and returns a\nteardown function:\n\n```typescript\nimport { pikkuTriggerFunc } from '#pikku'\n\nconst source = pikkuTriggerFunc<\n InputType, // Configuration input\n EventType // Shape of events it emits\n>(async (services, input, { trigger }) => {\n // Set up listener...\n trigger.invoke(eventData) // Fire the trigger\n\n // Return cleanup function\n return async () => {\n /* teardown */\n }\n})\n```\n\nIt receives **singleton services only** — there is no session, no request and no\nper-wire services, because a listener outlives every event it will ever emit.\nThe config-object form (`pikkuTriggerFunc({ func, title, description, tags,\ninput, output })`) is also accepted when you want schemas or metadata on the\nsource.\n\n## Starting triggers\n\nNothing fires until a `TriggerService` is started. For a single process,\n`InMemoryTriggerService` walks every wired source that has at least one matching\ntarget and sets it up:\n\n```typescript\nimport { InMemoryTriggerService } from '@pikku/core/services'\n\nconst triggerService = new InMemoryTriggerService()\nawait triggerService.start()\n// on shutdown\nawait triggerService.stop() // runs every source's teardown\n```\n\nA source with no matching `wireTrigger` is logged and skipped rather than\nerroring — the two halves are wired independently, so a half-wired trigger is a\nnormal intermediate state.\n\n**If a wiring is silently skipped**, look for\n`Skipping trigger … metadata not found` in the logs. Both wirings read metadata\ngenerated by the inspector, and it warns rather than throwing; the usual fix is\nthe one the warning suggests — move the wiring into its own file so codegen\npicks it up.\n\n## Usage Patterns\n\n### Redis Pub/Sub Source\n\n```typescript\nconst redisSubscribe = pikkuTriggerFunc<\n { channels: string[] },\n { channel: string; message: any }\n>(async ({ redis }, { channels }, { trigger }) => {\n const subscriber = redis.duplicate()\n\n subscriber.on('message', (channel, message) => {\n trigger.invoke({ channel, message: JSON.parse(message) })\n })\n\n await subscriber.subscribe(...channels)\n\n return async () => {\n await subscriber.unsubscribe()\n await subscriber.quit()\n }\n})\n\n// Target function\nconst onOrderEvent = pikkuSessionlessFunc({\n title: 'On Order Event',\n func: async ({ db, logger }, { channel, message }) => {\n logger.info(`Order event on ${channel}`, message)\n await db.processOrderEvent(message)\n },\n})\n\n// Wire them together\nwireTrigger({\n name: 'order-events',\n func: onOrderEvent,\n})\n\nwireTriggerSource({\n name: 'order-events',\n func: redisSubscribe,\n input: { channels: ['orders:created', 'orders:updated'] },\n})\n```\n\n### Triggers vs Queues\n\n| Feature | Trigger | Queue |\n| ----------- | ---------------------------------- | ------------------------------ |\n| Execution | Synchronous, in-process | Async, distributed |\n| Reliability | At-most-once | At-least-once (with retries) |\n| Use case | React to events immediately | Reliable background processing |\n| Source | External systems (Redis, PG, etc.) | Enqueued programmatically |\n\nUse triggers for real-time reactions. Use queues for reliable, retryable background work.\n\n## Complete Example\n\n```typescript\n// functions/triggers.functions.ts\nconst pgListen = pikkuTriggerFunc<{ channel: string }, { payload: any }>(\n async ({ db }, { channel }, { trigger }) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(channel)) {\n throw new Error(`Invalid channel name: ${channel}`)\n }\n const client = await db.pool.connect()\n\n client.on('notification', (msg) => {\n trigger.invoke({ payload: JSON.parse(msg.payload) })\n })\n\n await client.query(`LISTEN ${channel}`)\n\n return async () => {\n await client.query(`UNLISTEN ${channel}`)\n client.release()\n }\n }\n)\n\nconst onUserCreated = pikkuSessionlessFunc({\n title: 'On User Created',\n func: async ({ emailService, logger }, { payload }) => {\n logger.info('New user created', { userId: payload.id })\n await emailService.sendWelcome(payload.email)\n },\n})\n\n// wirings/triggers.wiring.ts\nwireTrigger({ name: 'user-created', func: onUserCreated })\nwireTriggerSource({\n name: 'user-created',\n func: pgListen,\n input: { channel: 'user_created' },\n})\n```\n", "pikku-versioning/SKILL.md": "---\nname: pikku-versioning\ndescription: >-\n Use when versioning Pikku function contracts, detecting breaking changes, or managing API\n backward compatibility. Covers the version property, versions.pikku.json manifest, contract\n hashing, and CI integration. TRIGGER when: code uses version: on a pikkuFunc, user asks about\n API versioning, breaking changes, contract hashes, backward compatibility, or \"pikku versions\"\n CLI commands. DO NOT TRIGGER when: user asks about secrets/variables/OAuth2 (use pikku-config)\n or general function definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku Function Versioning\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nTrack and protect function contracts across releases. Pikku hashes each function's input/output schema into a manifest so you can detect breaking changes before they ship.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their versions\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## Function Versioning\n\nA function with `version: N` is registered under the id `name@vN`. The bare\nname still resolves to it, so callers that don't care about versions keep\nworking while a pinned `getBook@v1` stays addressable for the ones that do.\n\n**The pattern:** when you need to introduce a breaking change, copy the current\nfunction into a pinned `v1` and bump the live one to `version: 2`.\n\n1. Create `my-function-v1.function.ts` exporting `getBookV1` with `version: 1` —\n the trailing `V1` matching the version is stripped automatically, so the id\n becomes `getBook@v1`\n2. Add `version: 2` to the existing `getBook`\n\n```typescript\n// my-function-v1.function.ts — old contract, kept for running workflows/agents\nexport const getBookV1 = pikkuFunc({\n version: 1, // id becomes getBook@v1 — the V1 suffix is stripped\n input: z.object({ bookId: z.string() }),\n output: z.object({ title: z.string() }),\n func: async ({ db }, { bookId }) => {\n return db.getBook(bookId)\n },\n})\n\n// my-function.function.ts — latest contract, id becomes getBook@v2\nexport const getBook = pikkuFunc({\n version: 2,\n input: z.object({\n bookId: z.string(),\n format: z.enum(['full', 'summary']),\n }),\n output: z.object({\n title: z.string(),\n author: z.string(),\n isbn: z.string(),\n }),\n func: async ({ db }, { bookId, format }) => {\n return db.getBook(bookId, format)\n },\n})\n```\n\n**Bump the live function explicitly.** Nothing promotes an unversioned function\nto the next version for you — without `version: 2` it is treated as version 1 of\nthe `getBook` contract, colliding with the pinned `getBook@v1` and making\n`versions check` report the published contract as modified.\n\n**`override` is the escape hatch, not the requirement.** The contract key comes\nfrom the exported name with a matching `V<n>` suffix removed, so\n`getBookV1` + `version: 1` already lands on `getBook`. Use\n`override: 'getBook'` only when the export can't follow that convention — for\ninstance `legacyGetBook` with `version: 1`, which would otherwise key under\n`legacyGetBook`.\n\n## Version Manifest (`versions.pikku.json`)\n\nPikku tracks contract hashes to detect breaking changes:\n\n```json\n{\n \"manifestVersion\": 1,\n \"contracts\": {\n \"createTodo\": {\n \"latest\": 1,\n \"versions\": {\n \"1\": { \"inputHash\": \"a1b2c3d4\", \"outputHash\": \"e5f6a7b8\" }\n }\n },\n \"getTodos\": {\n \"latest\": 2,\n \"versions\": {\n \"1\": { \"inputHash\": \"i9j0k1l2\", \"outputHash\": \"m3n4o5p6\" },\n \"2\": { \"inputHash\": \"q7r8s9t0\", \"outputHash\": \"u1v2w3x4\" }\n }\n }\n }\n}\n```\n\nEach hash is derived from the function's input and output schemas plus the\ncontract key. If a schema changes without a version bump, `pikku versions check`\nwill fail.\n\nThe manifest lives at `versions.pikku.json` in the project's `rootDir`, and its\npresence is what switches versioning on — with no manifest, nothing is checked.\n\n## CLI Commands\n\n```bash\nnpx pikku versions init # Create an empty versioning manifest (run once)\nnpx pikku versions check # Detect contract changes (use in CI)\nnpx pikku versions update # Record current contract hashes\n```\n\n`init` writes `{ \"manifestVersion\": 1, \"contracts\": {} }` and nothing more — it\ndoes **not** capture the hashes of the functions you already have. Run\n`versions update` straight after it to record the current state, otherwise\n`check` has nothing to compare against and silently passes.\n\n`update` refuses to save when a published version's hash changed, so it can\nnever overwrite an immutable record; it reports that as a diagnostic and leaves\nthe manifest alone. Fix the contract or bump the version, then run it again.\n\n**Workflow:**\n\n1. `pikku versions init` then `pikku versions update` — once, to create and\n populate `versions.pikku.json`\n2. Develop normally — add/modify functions\n3. `pikku versions check` — CI catches unversioned breaking changes\n4. If intentional: pin the old contract as `…V1` with `version: 1`, bump the\n live function to `version: 2`, then `pikku versions update`\n\n## CI Integration\n\n```yaml\n# .github/workflows/ci.yml\nname: CI\non: [push, pull_request]\n\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: npm ci\n - run: npx pikku versions check\n```\n\n## Complete Example\n\n```typescript\n// create-todo-v1.function.ts — v1 locked contract, id: createTodo@v1\nexport const createTodoV1 = pikkuSessionlessFunc({\n version: 1,\n input: z.object({ title: z.string() }),\n output: z.object({ id: z.string(), title: z.string() }),\n func: async ({ todoStore }, { title }) => todoStore.add(title),\n})\n\n// create-todo.function.ts — v2 (latest), called by default\nexport const createTodo = pikkuSessionlessFunc({\n version: 2,\n input: z.object({\n title: z.string(),\n priority: z.enum(['low', 'medium', 'high']),\n }),\n output: z.object({\n id: z.string(),\n title: z.string(),\n priority: z.string(),\n }),\n func: async ({ todoStore }, { title, priority }) =>\n todoStore.add(title, priority),\n})\n```\n\nResult in manifest:\n\n```json\n\"createTodo\": {\n \"latest\": 2,\n \"versions\": {\n \"1\": { \"inputHash\": \"...\", \"outputHash\": \"...\" },\n \"2\": { \"inputHash\": \"...\", \"outputHash\": \"...\" }\n }\n}\n```\n", "pikku-websocket/SKILL.md": "---\nname: pikku-websocket\ndescription: >-\n Use when adding real-time features, WebSocket channels, live updates, chat, or pub/sub to a\n Pikku app. Covers wireChannel, action routing, auth, EventHub pub/sub, channel middleware, and\n generated WebSocket client. TRIGGER when: code uses wireChannel, user asks about WebSocket,\n real-time, live updates, chat, pub/sub, or the generated WebSocket client. DO NOT TRIGGER when:\n user asks about HTTP/REST (use pikku-http), SSE (use pikku-http with sse: true), or WebSocket\n deployment specifics (use pikku-deploy-uws), or typed pub/sub events (use pikku-realtime).\ninstallGroups: [core]\n---\n\n# Pikku WebSocket Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to WebSocket channels with structured message routing, auth per-action, pub/sub via EventHub, and auto-generated type-safe clients.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nFollow existing patterns. See `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireChannel(config)`\n\n```typescript\nimport { wireChannel } from '@pikku/core/channel'\n\nwireChannel({\n name: string, // Channel name (e.g. 'todos')\n route: string, // REQUIRED — the URL path (e.g. '/todos')\n auth?: boolean, // Channel-level auth default\n onConnect?: PikkuFunc, // Called when client connects\n onDisconnect?: PikkuFunc, // Called when client disconnects\n onMessage?: PikkuFunc, // Catch-all for unrouted messages\n onMessageWiring?: { // TWO levels — see below\n [messageField: string]: {\n [fieldValue: string]: {\n func: PikkuFunc,\n auth?: boolean, // Override channel-level auth\n middleware?: PikkuMiddleware[],\n }\n }\n },\n middleware?: PikkuMiddleware[],\n channelMiddleware?: PikkuChannelMiddleware[],\n binary?: boolean | null,\n onBinaryMessage?: (services, data, channel) => ...,\n tags?: string[], // Targets tag middleware\n})\n```\n\nNote there is **no `permissions` key on a message wiring** — wire-level\npermissions were removed in #972. Authorization lives on the function's own\n`permissions` field (see `pikku-permissions`).\n\n### `pikkuChannelMiddleware(fn)`\n\n```typescript\nimport { pikkuChannelMiddleware } from '@pikku/core'\n\nconst middleware = pikkuChannelMiddleware(async (services, event, next) => {\n // Transform or filter events before/after\n await next(event) // Pass modified event, or next(null) to drop\n})\n```\n\n### `addChannelMiddleware(domain, middlewares)`\n\n```typescript\naddChannelMiddleware('todos', [addTimestamp, filterSensitive])\n```\n\n## Usage Patterns\n\n### Basic Channel\n\n```typescript\nwireChannel({\n name: 'todos',\n route: '/todos',\n onMessageWiring: {\n action: { // ← the field to route on\n create: { func: createTodo }, // ← its possible values\n list: { func: listTodos, auth: false },\n },\n },\n})\n```\n\n### Action Routing with Auth\n\n`onMessageWiring` nests two levels because the routing key is configurable. The\n**outer** key names the field in the incoming message to dispatch on; the\n**inner** keys are the values that field can take. With the conventional outer\nkey `action`, a client sending `{ action: 'create', data: {...} }` reaches\n`createTodo` — but a CLI channel might route on `command` instead, which is why\nthe field is not hardcoded.\n\n```typescript\nconst authenticate = pikkuSessionlessFunc({\n title: 'Authenticate',\n // setSession lives on the WIRE (third param), not on services\n func: async (services, { token }, { setSession }) => {\n const session = await verifyJWT(token)\n await setSession(session)\n return { success: true }\n },\n})\n\nwireChannel({\n name: 'todos',\n route: '/todos',\n auth: true,\n onMessageWiring: {\n action: {\n authenticate: { func: authenticate, auth: false }, // No session required\n subscribe: { func: subscribeTodos }, // Session required\n create: { func: createTodo },\n },\n },\n})\n```\n\n### Pub/Sub with EventHub\n\nUse EventHub for real-time broadcasting across connections:\n\n```typescript\nwireChannel({\n name: 'todos',\n route: '/todos',\n // eventHub is a service (1st param); channel lives on the wire (3rd)\n onConnect: async ({ eventHub }, _data, { channel }) => {\n eventHub.subscribe('todos:updated', (data) => {\n channel.send(data)\n })\n },\n onMessageWiring: {\n action: {\n create: {\n func: pikkuFunc({\n title: 'Create Todo',\n func: async ({ db, eventHub }, { text }) => {\n const todo = await db.createTodo({ text })\n eventHub.publish('todos:updated', {\n event: 'created',\n todo,\n })\n return { todo }\n },\n }),\n },\n },\n },\n})\n```\n\n### Channel Middleware\n\n```typescript\nconst addTimestamp = pikkuChannelMiddleware(\n async ({ logger }, event, next) => {\n logger.info({ phase: 'before-send', event })\n await next({ ...event, sentAt: Date.now() })\n }\n)\n\nconst filterSensitive = pikkuChannelMiddleware(\n async (_services, event, next) => {\n if (event.internal) return await next(null) // Drop event\n await next(event)\n }\n)\n\n// Apply globally to a domain\naddChannelMiddleware('todos', [addTimestamp, filterSensitive])\n\n// Or inline on wiring\nwireChannel({\n name: 'todos',\n route: '/todos',\n channelMiddleware: [addTimestamp],\n onMessageWiring: { ... },\n})\n```\n\n### Generated WebSocket Client\n\nAfter `npx pikku all`:\n\n```typescript\nimport { PikkuWebSocket } from '#pikku/pikku-websocket.gen.js'\n\nconst pikku = new PikkuWebSocket(ws)\nconst todosRoute = pikku.getRoute('todos')\n\n// Send action (type-safe)\nconst result = await todosRoute.send('create', { text: 'Buy milk' })\n\n// Subscribe to events\ntodosRoute.subscribe('todos:updated', (data) => {\n console.log(data.event, data.todo)\n})\n```\n\n## Complete Example\n\n```typescript\n// functions/chat.functions.ts\nexport const authenticate = pikkuSessionlessFunc({\n title: 'Authenticate',\n func: async ({ jwt }, { token }, { setSession }) => {\n const payload = await jwt.verify(token)\n setSession({ userId: payload.userId })\n return { success: true }\n },\n})\n\nexport const sendMessage = pikkuFunc({\n title: 'Send Message',\n func: async ({ db, eventHub }, { text }, { session }) => {\n const message = await db.createMessage({\n text,\n userId: session.userId,\n })\n eventHub.publish('chat:message', { message })\n return { message }\n },\n})\n\nexport const listMessages = pikkuSessionlessFunc({\n title: 'List Messages',\n func: async ({ db }, { limit }) => {\n return { messages: await db.listMessages(limit) }\n },\n})\n\n// wirings/chat.channel.ts\nwireChannel({\n name: 'chat',\n route: '/chat',\n auth: true,\n onConnect: async ({ eventHub }, _data, { channel }) => {\n eventHub.subscribe('chat:message', (data) => {\n channel.send(data)\n })\n },\n onMessageWiring: {\n action: {\n authenticate: { func: authenticate, auth: false },\n send: { func: sendMessage },\n history: { func: listMessages, auth: false },\n },\n },\n})\n```\n", "pikku-workflow/references/workflow-reference.md": "# Pikku Workflow Reference\n\n## Step execution: inline vs queue dispatch\n\nWhether a step runs **inline** (same process/session, no queue round-trip) or is **dispatched to the queue** is decided **purely by the step's function** — there is no workflow-level or per-call dispatch flag. `workflow.do(...)` options are only `description`/`retries`/`retryDelay`/`onError`.\n\n- **Steps default to inline.** Most steps don't need their own worker; running them inline avoids a queue round-trip per step, so a normally-started workflow executes its whole chain in one orchestrator pass.\n- **`workflowQueued: true` opts a function out.** Set it on the **function config** (`pikkuFunc` / `pikkuSessionlessFunc`, same level as `auth`/`expose`) to dispatch that step via the queue — for expensive/long-running steps that deserve their own worker, retry isolation, and concurrency limits. `workflowRetries` and `workflowTimeout` sit alongside it.\n- **Run-level `inline` is separate** and only controls whether the *whole run* executes in-process without queue infrastructure (set automatically when there is no `queueService`, or via `startWorkflow(..., { inline: true })`). It governs sleep handling, not per-step dispatch.\n\nThe rule (`dispatchStep`):\n\n| Function `workflowQueued` | `queueService` present? | Result |\n|---|---|---|\n| default / `false` | any | **inline** |\n| `true` | yes | **queued** (own worker) |\n| `true` | no | **throws** |\n\n```typescript\n// Push this one expensive step onto the queue; every other step stays inline:\nexport const renderLargeReport = pikkuSessionlessFunc({\n workflowQueued: true, // dispatch via queue instead of running inline\n workflowRetries: 3,\n workflowTimeout: '5m',\n input: ReportInput,\n output: ReportOutput,\n func: async (services, data) => { /* ... */ },\n})\n```\n\n`workflowQueued: true` **requires** a `queueService`. Without one the step throws\nrather than quietly running inline — a step marked for its own worker usually\ncarries timeout and concurrency expectations that inline execution would silently\nviolate, so failing loudly is safer than proceeding.\n\n## HTTP workflow wiring (manual)\n\nUsually auto-scaffolded via `scaffold.workflow`. To wire by hand:\n\n```typescript\n// Start a workflow\nwireHTTP({ method: 'post', route: '/onboard', func: workflowStart('onboardUser') })\n\n// Execute workflow steps (called by the orchestrator)\nwireHTTP({ method: 'post', route: '/onboard/run', func: workflow('onboardUser') })\n\n// Check workflow status\nwireHTTP({ method: 'get', route: '/onboard/status/:runId', func: workflowStatus('onboardUser') })\n```\n\n## Suspend / resume example\n\n```typescript\nimport { z } from 'zod'\nimport { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nexport const approval = pikkuWorkflowFunc({\n description: 'Submit a request and wait for approval',\n input: z.object({ requestId: z.string() }),\n output: z.object({ approved: z.boolean() }),\n func: async (services, data, { workflow }) => {\n await workflow.do('Submit request', 'submitRequest', data)\n await workflow.suspend('Awaiting approval') // pauses here until externally resumed\n const result = await workflow.do('Check result', 'getApprovalResult', data)\n return { approved: result.approved }\n },\n})\n```\n", "pikku-workflow/SKILL.md": "---\nname: pikku-workflow\ndescription: >-\n Use when building multi-step workflows, state machines, or orchestration pipelines with Pikku.\n Covers pikkuWorkflowFunc, workflow steps (do, sleep, suspend), graph workflows, and HTTP wiring.\n TRIGGER when: code uses pikkuWorkflowFunc/pikkuWorkflowGraph, user asks about workflows,\n multi-step processes, durable execution, suspend/resume, or DAG orchestration. DO NOT TRIGGER\n when: user asks about simple background jobs (use pikku-queue) or scheduled tasks (use\n pikku-cron).\ninstallGroups: [core]\n---\n\n# Pikku Workflow Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Capture baseline. Run `pikku-verify` (or `pikku all`) BEFORE writing code; note existing errors — only NEW errors are yours to fix.\n2. Discover before editing. Prefer `pikku-meta` / `pikku info functions --verbose` and `pikku info tags --verbose` to see functions usable as steps and project organization; inspect only the focused output you need.\n3. Identify the source files that own the behavior. Do not start from generated output, `.pikku`, `node_modules`, vendored packages, or build artifacts.\n4. Make the smallest source change. Keep generated files generated — never hand-edit SDKs, schema output, or typegen to paper over errors; fix the source cause.\n5. Validate with the narrowest relevant command, then re-run `pikku-verify`. If only files you did not touch still error, those are pre-existing — leave them unless asked.\n6. Call `pikku-workflow-view` only when `pikku-verify` fully passes (codegen AND type check both green) — never after a partial pass.\n\nSee `pikku-concepts` for the core mental model.\n\nBuild durable, multi-step workflows with automatic retry, sleep, suspend/resume, and parallel execution. Steps are cached for replay safety.\n\n## Choosing the right factory\n\n| Factory | When to use | Step-graph view? |\n| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `pikkuWorkflowFunc` | **Default for all new workflows.** Sequential + conditional logic; DSL mode (serialisable, replay-safe). ALL `const`/`let` declarations must be at the top level of the function body (not inside blocks). | ✅ Yes |\n| `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |\n| `pikkuWorkflowComplexFunc` | Escape hatch only — arbitrary TypeScript, no top-level restriction (e.g. dynamic inline functions the DSL extractor cannot handle). | ❌ No (loses step-graph view) |\n\n**Default to `pikkuWorkflowFunc`.** Use `pikkuWorkflowGraph` ONLY with explicit user approval AND only for a genuine cyclic dependency or Node.js-only import DSL cannot express. Use `pikkuWorkflowComplexFunc` ONLY with explicit user approval — a last-resort escape hatch. Never switch to either just to dodge a PKU641 error; restructure the code instead.\n\n### PKU641 — DSL static analysis error\n\n`pikkuWorkflowFunc` statically analyzes the body: **every `const`/`let` must be top-level, not inside any block (`if`, `for`, `while`, …).** Assignments inside blocks are fine — only declarations trigger it.\n\n```typescript\n// ❌ PKU641 — declaration inside block\nif (priority === 'high') {\n const bugCard = await workflow.do(...)\n}\n\n// ✅ hoist the declaration, assign inside the block\nlet bugCard: Awaited<ReturnType<typeof workflow.do>>\nif (priority === 'high') {\n bugCard = await workflow.do(...)\n}\n```\n\n## Import path\n\n```typescript\n// CORRECT — workflow factories come from the generated types file\nimport {\n pikkuWorkflowFunc,\n pikkuWorkflowGraph,\n pikkuWorkflowComplexFunc,\n} from '#pikku/workflow/pikku-workflow-types.gen.js'\n\n// WRONG — '#pikku' does not re-export them (TS2305)\nimport { pikkuWorkflowFunc } from '#pikku'\n```\n\n## Defining a workflow\n\nDeclare input/output as Zod schemas (like any function) — never TypeScript generic params (no `pikkuWorkflowFunc<In, Out>(...)`; that skips runtime validation). `data` is typed from the input schema.\n\n```typescript\nimport { z } from 'zod'\nimport { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nconst ProcessOrderInput = z.object({ orderId: z.string(), amount: z.number() })\nconst ProcessOrderOutput = z.object({\n status: z.string(),\n discount: z.number().optional(),\n})\n\nexport const processOrder = pikkuWorkflowFunc({\n description: 'Process an order through payment and fulfillment',\n tags: ['orders'],\n input: ProcessOrderInput,\n output: ProcessOrderOutput,\n func: async (services, data, { workflow }) => {\n // Declare ALL variables at top level — even those only assigned in branches (PKU641)\n let discount: number | undefined\n let status: string\n\n if (data.amount > 1000) {\n const d = await workflow.do('Apply bulk discount', 'calcDiscount', {\n amount: data.amount,\n })\n discount = d.discountPercent\n }\n\n const payment = await workflow.do('Charge', 'chargePayment', {\n orderId: data.orderId,\n amount: discount ? data.amount * (1 - discount / 100) : data.amount,\n })\n\n if (payment.success) {\n await workflow.do('Fulfill', 'fulfillOrder', { orderId: data.orderId })\n status = 'fulfilled'\n } else {\n status = 'payment-failed'\n }\n\n return { status, discount }\n },\n})\n```\n\n### Workflow step types\n\n```typescript\n// RPC step — run a registered Pikku function as a step (opts: retries, retryDelay, description)\nconst result = await workflow.do('Step name', 'rpcFunctionName', { ...data }, { retries: 3, retryDelay: '1s' })\n\n// Inline closure step — immediate execution, cached for replay\nconst msg = await workflow.do('Generate', async () => `Welcome, ${data.email}!`)\n\n// Sleep — durable pause (duration: '30s', '5min', '1h', '1d')\nawait workflow.sleep('Wait 5 minutes', '5min')\n\n// Suspend — pause until externally resumed (e.g. awaiting approval), then continue\nawait workflow.suspend('Awaiting approval')\n\n// Approval — suspend for a human decision and resume with the answer\nawait workflow.approval('Manager sign-off', { ... })\n```\n\n`workflow.name`, `workflow.runId` and `await workflow.getRun()` identify the\ncurrent run if a step needs to reference it.\n\n### Approval gates: who may answer\n\n`workflow.approval(reason, options)` takes a `schema` (a runtime value — the\npayload arrives from an untrusted caller, so a type generic would validate\nnothing), an optional `expiry`, and an optional policy for **who** may answer:\n\n```typescript\nconst signOff = await workflow.approval('Manager sign-off', {\n schema: SignOffSchema,\n expiry: '3d',\n approvers: 'not-initiator', // four-eyes: anyone but whoever started the run\n approverScope: 'payments:approve', // and they must hold this scope\n})\nif (signOff.status === 'expired') { ... }\n```\n\n`approvers` is one of:\n\n| value | who may answer |\n| ----------------- | -------------------------------------------------------------------------------------------------------- |\n| `any` _(default)_ | anyone the approve entrypoint admits — the gate is a pause for a decision, not an authorization boundary |\n| `owner` | only the user who started the run |\n| `not-initiator` | anyone **except** the user who started the run |\n\nBoth options are enforced in two phases, because a decision can legitimately\narrive before the run has reached the gate:\n\n- **At submission**, if the run has already reached the gate. Reaching it\n publishes the policy into the run state, so the approve entrypoint can judge\n the caller against it and refuse with a **403**.\n- **On replay**, for a decision that arrived before the gate — there was no\n policy to judge it against yet, so it is accepted and judged when the workflow\n reaches the gate. Failing there discards the decision and leaves the gate\n closed, exactly as a decision that fails the schema does.\n\nSo the same rejected decision surfaces as an HTTP error or as a silently\nre-closed gate depending on timing. Both are audited.\n\nA gate declaring neither option accepts a decision from anyone the approve\nroute lets through; gate the route with `auth`/`permissions` to narrow that.\n\n#### What survives the run\n\nA settled decision carries `decidedBy` and `decidedAt`, so the answer keeps its\nprovenance in the step result:\n\n```typescript\nif (signOff.status === 'decided') {\n logger.info(`signed by ${signOff.decidedBy?.userId} at ${signOff.decidedAt}`)\n}\n```\n\nThat record is deleted with the run, though — `deleteRun` cascades to steps and\nhistory — and an attempt that was _refused_ never reaches a step at all. So\nevery answer is also written to the audit sink as `workflow.approval.decided`,\nwith `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the\nrun, reason and refusal in `metadata`. Wire an `audit` service to keep it; a\nproject without one records nothing and is otherwise unaffected.\n\n### Error handling: `onError`, never try/catch\n\n**Do not wrap steps in try/catch.** The DSL extractor serialises the body into a\nstep graph, and a `catch` block is control flow it cannot represent — so the\ngraph would no longer describe what actually runs, which is the whole point of\nthe DSL mode. This is a settled design decision, not a temporary limitation.\n\nUse the `onError` step option instead: it names an RPC to invoke when the step\nhas failed _after_ exhausting its retries.\n\n```typescript\nawait workflow.do(\n 'Charge',\n 'chargePayment',\n { orderId },\n {\n retries: 3,\n retryDelay: '1s',\n onError: 'refundReservation', // compensation RPC\n }\n)\n```\n\nThe handler receives `{ error: { message } }`, and the original error is still\nthrown afterwards — so the workflow still fails. `onError` is **compensation, not\nrecovery**: it exists to undo work, not to swallow the failure and carry on. If\nyou genuinely need to branch on a failure, have the step return a result object\n(`{ success: false, reason }`) and branch on that, the way the `processOrder`\nexample branches on `payment.success`.\n\nFull step options: `description`, `retries`, `retryDelay`, `onError` (plus\n`actor`, which is scenario-only — see `pikku-scenario`).\n\n### Parallel fan-out\n\n```typescript\nconst users = await Promise.all(\n data.userIds.map((userId) =>\n workflow.do(`Fetch user ${userId}`, 'getUser', { userId })\n )\n)\n```\n\n### Graph workflow (DAG)\n\n`pikkuWorkflowGraph` derives types from the RPC map — no explicit `input`/`output`. Nodes map `nodeName → Pikku function name`; `config.<node>.next` lists nodes to run after it (in parallel); `config.<node>.input: (ref) => ...` transforms input using refs to prior node outputs.\n\n```typescript\nimport { pikkuWorkflowGraph } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nexport const userOnboarding = pikkuWorkflowGraph({\n description: 'Onboard a new user',\n nodes: {\n createProfile: 'createUserProfile',\n sendWelcome: 'sendEmail',\n setupDefaults: 'createDefaultTodos',\n },\n config: {\n createProfile: { next: ['sendWelcome', 'setupDefaults'] }, // run in parallel\n sendWelcome: {\n input: (ref) => ({\n to: ref('createProfile', 'email'),\n subject: 'Welcome!',\n }),\n },\n },\n})\n```\n\n## File conventions\n\n- Place workflows in `packages/functions/src/wirings/*.workflow.ts`; export the variable so the inspector discovers it (no manual registration).\n- HTTP start/run/status routes are auto-scaffolded via `scaffold.workflow` in `pikku.config.json`.\n\n## Step dispatch & HTTP wiring\n\nFor per-step inline-vs-queue dispatch (`workflowQueued: true` and the `dispatchStep` rules), the manual `workflowStart`/`workflow`/`workflowStatus` HTTP wirings, and a suspend/resume example, read `references/workflow-reference.md`.\n\n## After writing\n\n1. `pikku-verify` (codegen + tsc).\n2. PKU641 → a `const`/`let` is inside a block; hoist it to the top of the function body.\n3. Import errors → use `#pikku/workflow/pikku-workflow-types.gen.js`, not `#pikku`.\n4. Type errors only in files you did not touch → pre-existing template errors; safe to ignore.\n5. Both green → call `pikku-workflow-view` with the workflow name.\n", "pikku-workflows-client/SKILL.md": "---\nname: pikku-workflows-client\ndescription: 'Run Pikku workflows from a React frontend and track their progress. Covers `useRunWorkflow` (run-and-wait), `useStartWorkflow` (fire-and-poll), and `useWorkflowStatus` (live status). TRIGGER when: a React component needs to invoke or display the status of a Pikku workflow, the user mentions long-running tasks / background jobs / progress UI tied to a workflow, or asks how to start/track a workflow from the client. DO NOT TRIGGER when: the user is wiring the workflow itself (use pikku-workflow) or only making regular RPC calls (use pikku-react-query).'\ninstallGroups: [core]\n---\n\n# Pikku Workflows — Client Hooks\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWhen a project defines any workflow — DSL or `pikkuWorkflowGraph` — three\nReact Query hooks are auto-generated alongside the standard RPC hooks. They handle\nthe two common shapes: **run-and-wait** (short workflows where the\nclient waits for the result) and **fire-and-poll** (long workflows where\nthe client gets a `runId` and polls status).\n\n## Discover what workflows exist\n\n```bash\nyarn pikku meta clients --json | jq '.workflows'\n```\n\nEach entry has `name`, `description`, `mode` (inline | distributed), plus\n`input` / `output` type names. Pass the workflow **name** to the hooks\nbelow.\n\n## Setup\n\nThese hooks are generated into the same `api.gen.ts` as `usePikkuQuery` —\nno extra setup beyond `PikkuProvider` + `QueryClientProvider` (see the\n**pikku-react** and **pikku-react-query** skills).\n\n## `useRunWorkflow(name, options?)` — run and wait\n\nFor short, synchronous-feeling workflows. Returns a mutation that\nresolves to the workflow's output.\n\n```tsx\nimport { useRunWorkflow } from './pikku/api.gen'\n\nfunction ChargeButton({ orderId }: { orderId: string }) {\n const run = useRunWorkflow('chargeOrder', {\n onSuccess: (output) => toast.success(`Charged: $${output.amount}`),\n })\n return (\n <button onClick={() => run.mutate({ orderId })} disabled={run.isPending}>\n {run.isPending ? 'Charging…' : 'Charge'}\n </button>\n )\n}\n```\n\nUse this when the workflow finishes in seconds and the UI can hold open\na loading state until done.\n\n## `useStartWorkflow(name, options?)` — fire-and-poll\n\nReturns a mutation that resolves to `{ runId: string }` immediately. The\nworkflow keeps running on the server. Pair with `useWorkflowStatus` to\nrender progress.\n\n```tsx\nconst start = useStartWorkflow('processVideo', {\n onSuccess: ({ runId }) => setActiveRunId(runId),\n})\n\nstart.mutate({ videoId: '123' })\n```\n\nUse this for long-running workflows (uploads, batch jobs, AI generation,\nanything you'd want a progress bar for).\n\n## `useWorkflowStatus(workflowName, runId, options?)` — observe\n\nPolls the workflow runtime for a run's status. Returns a typed status\nobject with `status`, optional `output`, and optional `error`.\n\n```tsx\nimport { useWorkflowStatus } from './pikku/api.gen'\n\nfunction VideoStatus({ runId }: { runId: string }) {\n const { data: status } = useWorkflowStatus('processVideo', runId, {\n refetchInterval: (query) =>\n query.state.data?.status === 'running' ? 1000 : false,\n })\n\n if (!status) return null\n if (status.status === 'running') return <Spinner />\n if (status.status === 'completed') return <Result {...status.output} />\n if (status.status === 'failed')\n return <Error message={status.error?.message} />\n return null\n}\n```\n\nStatus values: `'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'`.\n\n**Stopping the poll is your job.** The hook adds no terminal-state logic of its\nown — the `refetchInterval` callback above is what ends it, by returning `false`\nonce `status` is no longer `running`. Leave that out and a finished run keeps\nbeing polled forever.\n\nThe hook is disabled until `runId` is set, so passing `undefined` while the run\nhas not started yet is the intended shape rather than something to guard around.\nThat `enabled` is owned by the hook and cannot be overridden through `options`.\n\n## Putting it together — start + observe\n\n```tsx\nfunction ProcessVideoFlow({ videoId }: { videoId: string }) {\n const [runId, setRunId] = useState<string>()\n const start = useStartWorkflow('processVideo', {\n onSuccess: ({ runId }) => setRunId(runId),\n })\n const status = useWorkflowStatus('processVideo', runId)\n\n if (!runId) {\n return (\n <button\n onClick={() => start.mutate({ videoId })}\n disabled={start.isPending}\n >\n Start\n </button>\n )\n }\n return <ProgressBar status={status.data?.status} />\n}\n```\n\n## Backend: streaming richer progress\n\nThe status hook returns a coarse-grained state machine (`running`,\n`completed`, etc.). For step-by-step updates inside a long workflow,\npublish events from the workflow itself via `eventHub` or open a\nWebSocket channel — out of scope for this skill (see workflow + channel\ndocs).\n\n## What NOT to do\n\n- Don't poll status manually with `setInterval` — use `useWorkflowStatus`\n with a `refetchInterval` callback, which dedupes across components and\n lets you stop on a terminal state in one place.\n- Don't call `useRunWorkflow` for workflows that take more than a few\n seconds. The user-facing component will hold a long-running pending\n state with no progress indication; use start + status instead.\n- Don't use these hooks for non-workflow RPCs — they only resolve\n workflow-shaped names. Regular RPCs go through `usePikkuQuery` /\n `usePikkuMutation`.\n", "pikku-ws/SKILL.md": "---\nname: pikku-ws\ndescription: >-\n Use when setting up a WebSocket server with the ws library in a Pikku app. Covers the ws runtime\n adapter for Pikku channels. TRIGGER when: code uses @pikku/ws, user asks about ws library\n WebSocket server, or Node.js WebSocket runtime. DO NOT TRIGGER when: user asks about WebSocket\n wiring/channels (use pikku-websocket) or uWebSockets (use pikku-deploy-uws).\n---\n\n# Pikku WS (WebSocket Server Runtime)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/ws` provides a WebSocket server runtime using the [ws](https://github.com/websockets/ws) library, connecting Pikku's channel system to a Node.js WebSocket server.\n\n## Installation\n\n```bash\nyarn add @pikku/ws ws\n```\n\n## Usage Patterns\n\n### Basic Setup\n\nThe package exports one function, `pikkuWebsocketHandler` — there is no server\nclass. You own the `http.Server` and the `WebSocketServer`; the handler attaches\nthe upgrade and message plumbing to them.\n\n```typescript\nimport { pikkuWebsocketHandler } from '@pikku/ws'\nimport { stopSingletonServices } from '@pikku/core'\nimport { Server } from 'http'\nimport { WebSocketServer } from 'ws'\n\nimport '../.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst server = new Server()\nconst wss = new WebSocketServer({ noServer: true })\n\npikkuWebsocketHandler({\n server,\n wss,\n logger: singletonServices.logger,\n logRoutes: true, // print the wired channels at startup\n loadSchemas: true, // compile input schemas up front\n})\n\nserver.listen(4002, 'localhost')\n```\n\n`noServer: true` is not optional decoration — the handler performs the upgrade\nitself so it can run pikku's HTTP middleware chain (auth, cors) against the\nupgrade request before a channel exists. Letting `ws` bind the server directly\nwould skip that.\n\nServices come from the bootstrap import and the global singleton registry, which\nis why nothing is passed in. The event hub is taken from\n`singletonServices.eventHub` when it is a `LocalEventHubService`, and a local one\nis created otherwise — so a single-process app gets pub/sub for free, while a\nmulti-instance deployment must register a distributed hub (see `pikku-realtime`).\n\nThe options type also extends `RunHTTPWiringOptions`, so per-request settings\nsuch as `respondWith404`, `coerceDataFromSchema` and `bubbleErrors` are accepted\nhere too.\n\nOn shutdown, call `stopSingletonServices()` then close `wss` and `server`.\n\nSee `pikku-websocket` for channel wiring details, and\n`pikku-deploy-fastify`/`pikku-deploy-express` when the WebSocket server shares a\nport with an HTTP app.\n" };
|
|
7
|
+
export const SKILL_FILES = { "pikku-addon/references/addon-package-manifest.md": "# Addon Package Manifest Reference\n\n`npx pikku new addon` scaffolds these files. You rarely hand-edit them — consult this when wiring exports or config by hand.\n\n## Package Structure\n\n```text\nmy-addon/\n├── package.json # Exports .pikku/* and dist/\n├── pikku.config.json # addon: true + metadata\n├── tsconfig.json # #pikku path mapping\n├── src/\n│ ├── services.ts # createSingletonServices (required)\n│ └── functions/\n│ └── *.function.ts # Function definitions\n├── types/\n│ └── application-types.d.ts # SingletonServices interface\n└── .pikku/ # Generated (gitignored)\n```\n\n## pikku.config.json\n\n```json\n{\n \"tsconfig\": \"./tsconfig.json\",\n \"srcDirectories\": [\"src\", \"types\"],\n \"outDir\": \"./.pikku\",\n \"addon\": true,\n \"node\": {\n \"displayName\": \"My Addon\",\n \"description\": \"What this addon does\",\n \"categories\": [\"General\"]\n }\n}\n```\n\n## package.json (key fields)\n\n```json\n{\n \"name\": \"@my-org/addon-todos\",\n \"imports\": {\n \"#pikku\": \"./.pikku/pikku-types.gen.ts\",\n \"#pikku/*\": \"./.pikku/*\"\n },\n \"exports\": {\n \".\": { \"types\": \"./dist/src/index.d.ts\", \"import\": \"./dist/src/index.js\" },\n \"./.pikku/*\": \"./.pikku/*\",\n \"./.pikku/pikku-metadata.gen.json\": \"./.pikku/pikku-metadata.gen.json\",\n \"./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js\": {\n \"types\": \"./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.d.ts\"\n }\n },\n \"files\": [\"dist\", \".pikku\"],\n \"peerDependencies\": {\n \"@pikku/core\": \"*\"\n },\n \"scripts\": {\n \"pikku\": \"pikku all\",\n \"build\": \"tsc && cp -r .pikku dist/\"\n }\n}\n```\n", "pikku-addon/SKILL.md": "---\nname: pikku-addon\ndescription: >-\n Use when creating or consuming reusable function packages (addons) in Pikku. Covers wireAddon,\n ref(), pikkuAddonServices, pikkuAddonWireServices, addon package structure, and cross-project\n function sharing. TRIGGER when: code uses wireAddon/ref()/pikkuAddonServices, user asks about\n addons, reusable function packages, cross-project sharing, or addon package structure. DO NOT\n TRIGGER when: user asks about internal function composition (use pikku-rpc) or general function\n definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku Addons\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nAddons are reusable Pikku function packages that can be shared across projects. They bundle functions, services, secrets, and variables into a self-contained NPM package.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and addons\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireAddon(config)`\n\nRegister an addon in the consuming project:\n\n```typescript\nimport { wireAddon } from '#pikku'\n\nwireAddon({\n name: string, // Namespace for addon functions (e.g. 'todos')\n package: string, // NPM package name (e.g. '@pikku/addon-todos')\n rpcEndpoint?: string, // Optional remote RPC endpoint for distributed execution\n auth?: boolean, // Require a session for every function in the addon\n mcp?: boolean,\n tags?: string[], // Tags applied to all addon functions\n scopes?: string[], // Required of every function, on top of its own\n secretOverrides?: Record<string, string>, // Remap secret names (and grant them)\n variableOverrides?: Record<string, string>, // Remap variable names\n credentialOverrides?: Record<string, string>, // Remap credential names (and grant them)\n secretGrants?: string[], // Secrets the app lends this addon\n credentialGrants?: string[], // Credentials the app lends this addon\n globalSecrets?: string, // Reason for handing over the whole SecretService\n globalCredentials?: string, // Reason for handing over the whole CredentialService\n})\n```\n\n**`auth`, `tags` and `scopes` only ever tighten.** `auth: false` is not honoured —\nit would weaken the wiring's own gate — so the addon-level setting can require a\nsession but never waive one. The same package wired twice under two namespaces is\ngoverned by the union of both instances' scopes and tags.\n\n### An addon reads only the secrets it declared\n\nAn addon's `SecretService` and `CredentialService` are **scoped**: it may read\nthe secrets its own source declares (literal `getSecret('X')` calls and\n`wireSecret` definitions, which the CLI collects into `declaredSecrets`) and\nnothing else. Anything undeclared throws `Access denied to secret key: X` at\nruntime. The same holds for credentials, and a scoped addon can never call\n`getAllUsers()`.\n\nThat works for an addon naming its own secrets. It does not work for a _generic_\naddon whose secret names arrive as data — `@pikku/addon-graph` reads\n`getSecret(auth.credential)`, where the name comes off the workflow node — so\nsuch an addon declares nothing and is scoped to nothing. Only the consuming app\ncan widen it, with one of three fields:\n\n```typescript\nwireAddon({\n name: 'graph',\n package: '@pikku/addon-graph',\n\n secretGrants: ['STRIPE_KEY'], // lend these, unrenamed\n secretOverrides: { MAILGUN_KEY: 'PROD_EMAIL_KEY' }, // lend + rename\n // globalSecrets: 'why no static list can cover it' // lend everything\n})\n```\n\n| field | meaning |\n| ----------------- | --------------------------------------- |\n| `secretOverrides` | grant **and** rename |\n| `secretGrants` | grant as-is |\n| `globalSecrets` | grant everything, with a written reason |\n\n**Grants name the secret as the addon reads it**, not as your project stores it.\nScoping is checked _before_ the override map renames anything, so an overridden\nsecret is granted by its addon-side key — which is also why an override's key\ngrants and its value does not. With no rename in play the two names coincide.\n\n`globalSecrets` / `globalCredentials` take the _reason_ for the grant, not a\nboolean, because every grant is enumerated in the deploy manifest\n(`unscopedSecretAddons`, `grantedSecretAddons`). Prefer `secretGrants` — reach\nfor `globalSecrets` only when no static list can exist, and never for an addon\nthat performs outbound requests, where an unrestricted secret read is an\nexfiltration primitive.\n\nA grant naming a secret your project does not declare is a build error from\n`pikku all`, resolved through the override map first:\n\n```\nSecret grant 'STIRPE_KEY' in addon 'graph' (@pikku/addon-graph) targets a secret\nthat does not exist. Available secrets: BETTER_AUTH_SECRET, GITHUB_OAUTH\n```\n\n### `ref(name)`\n\nType-safe reference to a function — local or addon — for use in any wiring. It\nreturns a function config that proxies the call via RPC at runtime:\n\n```typescript\nimport { ref } from '#pikku'\n\nref('todos:addTodo') // namespace:functionName for an addon function\nref('myLocalFunc') // a local function by name\n```\n\nThere is no `addon()` helper; `ref()` covers both. For an addon that publishes\n**wiring contracts** rather than bare functions, codegen also emits `refHTTP`,\n`refChannel` and `refCLI`, which carry the addon's own route/config metadata:\n\n```typescript\nimport { refHTTP } from '#pikku'\n\nwireHTTP(refHTTP('todos:listTodos', { basePath: '/api' }))\n```\n\n### `pikkuAddonServices(factory)`\n\nDefine singleton services for an addon package (created once at startup). The\nsecond argument is always present — an addon never falls back to its own logger,\nvariables or secrets; the consuming app supplies them:\n\n```typescript\nimport { pikkuAddonServices } from '#pikku'\n\nexport const createSingletonServices = pikkuAddonServices(\n async (config, { secrets, logger }) => {\n const creds =\n await secrets.getSecret<GithubCredentials>('GITHUB_CREDENTIALS')\n return { github: new GithubService(creds.reveal()) }\n }\n)\n```\n\n`secrets` and `variables` arrive **typed against the addon's own declarations**,\nand a secret is a `SecretValue` — `.reveal()` is the only way to the plaintext\n(see `pikku-config`). `pikkuAddonConfig` is the matching factory for the addon's\nconfig object.\n\n### `pikkuAddonWireServices(factory)`\n\nDefine per-request services for an addon package (created fresh per HTTP request, queue job, etc.):\n\n```typescript\nimport { pikkuAddonWireServices } from '#pikku'\n\nexport const createWireServices = pikkuAddonWireServices(\n async (singletonServices, wire) => {\n // wire: transport context (http, channel, session, etc.)\n const authHeader = wire.http?.request?.header('authorization')\n return {\n myService: new MyService(authHeader),\n }\n }\n)\n```\n\n## Creating an Addon\n\n### Scaffold\n\n```bash\nnpx pikku new addon <name> # name is a required positional\nnpx pikku new addon stripe --display-name Stripe --category Payments --dir addons\n```\n\nThis generates `package.json` (exports `.pikku/*` + `dist/`), `pikku.config.json` (`addon: true`), `tsconfig.json` (`#pikku` path mapping), `src/services.ts`, `src/functions/`, and `types/application-types.d.ts`. For the full file contents/exports you rarely hand-edit, read `references/addon-package-manifest.md`.\n\n### Services\n\n```typescript\n// src/services.ts\nimport { pikkuAddonServices, pikkuAddonWireServices } from '#pikku'\nimport { TodoStore } from './todo-store.service.js'\n\nexport const createSingletonServices = pikkuAddonServices(async () => {\n const todoStore = new TodoStore()\n return { todoStore }\n})\n\n// Optional — only needed if addon functions require per-request services\nexport const createWireServices = pikkuAddonWireServices(\n async (singletonServices, wire) => {\n return {}\n }\n)\n```\n\n### Functions\n\n```typescript\n// src/functions/addTodo.function.ts\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku'\n\nconst AddTodoInput = z.object({ title: z.string() })\nconst AddTodoOutput = z.object({ id: z.string(), title: z.string() })\n\nexport const addTodo = pikkuSessionlessFunc({\n description: 'Adds a new todo',\n input: AddTodoInput,\n output: AddTodoOutput,\n func: async ({ todoStore }, { title }) => {\n return todoStore.add(title)\n },\n})\n```\n\nOptional approval gating (e.g. for agent tools) — add `approvalRequired: true` plus an `approvalDescription` resolver:\n\n```typescript\napprovalRequired: true,\napprovalDescription: async (_services, { title }) => `Add a todo called \"${title}\"`,\n```\n\n### Build\n\n```bash\nyarn pikku all # Generate types\nyarn tsc # Compile TypeScript\ncp -r .pikku types dist/ # Ship the generated files and the types they import\nyarn pikku validate # Check the published file set holds together\n```\n\n`yarn pikku`, not `npx pikku`: a scaffolded addon carries `@pikku/cli` as a\ndevDependency, and building it against a different CLI than it declares is how\ngenerated output ends up disagreeing with the packaged one. `npx pikku new\naddon` above is the exception — it runs before the addon, and its CLI, exist.\n\n`types/` has to be copied alongside `.pikku`: the generated files import\n`SingletonServices`, `Services`, `Config` and `UserSession` from\n`../../types/application-types.d.js`, and `tsc` never emits a hand-written\n`.d.ts` to `outDir`, so nothing else puts it in `dist`. Leave it out and the\naddon installs fine and fails to typecheck in every app that depends on it —\nwhich is what `pikku validate` is there to catch before you publish.\n\n## Consuming an Addon\n\n### Install & Register\n\n```bash\nyarn add @my-org/addon-todos\n```\n\n```typescript\n// wirings/todos.wirings.ts\nimport { wireAddon } from '#pikku'\n\nwireAddon({ name: 'todos', package: '@my-org/addon-todos' })\n```\n\nAfter registration, run `yarn pikku all` to generate types for the addon's functions.\n\n### Call via RPC\n\n```typescript\nexport const myFunc = pikkuFunc({\n func: async (_services, data, { rpc }) => {\n const todo = await rpc.invoke('todos:addTodo', { title: 'Buy milk' })\n return todo\n },\n})\n```\n\n### Wire to HTTP\n\n```typescript\nimport { wireHTTP, ref } from '#pikku'\n\nwireHTTP({\n method: 'get',\n route: '/todos',\n func: ref('todos:listTodos'),\n auth: false,\n})\n```\n\nOr batch multiple addon routes with `defineHTTPRoutes` + `wireHTTPRoutes`:\n\n```typescript\nimport { wireHTTPRoutes, defineHTTPRoutes, ref } from '#pikku'\n\nconst todoRoutes = defineHTTPRoutes({\n tags: ['todos'],\n auth: false,\n routes: {\n list: { method: 'get', route: '/todos', func: ref('todos:listTodos') },\n add: { method: 'post', route: '/todos', func: ref('todos:addTodo') },\n },\n})\n\nwireHTTPRoutes({ basePath: '/api', routes: { todos: todoRoutes } })\n```\n\n### Use in AI Agents\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku'\n\nexport const todoAgent = pikkuAIAgent({\n name: 'todo-agent',\n description: 'Manages a todo list',\n goal: 'You help users manage their todos.',\n model: 'openai/gpt-5-mini',\n tools: [\n ref('todos:listTodos'),\n ref('todos:addTodo'),\n ref('todos:deleteTodo'),\n ],\n maxSteps: 5,\n})\n```\n\nSee `pikku-ai-agent` — an addon function is just another `ref()` in `tools`.\n", "pikku-ai-agent/SKILL.md": "---\nname: pikku-ai-agent\ndescription: >-\n Use when building AI agents, chatbots, or LLM-powered assistants with Pikku. Covers\n pikkuAIAgent, ref() tool registration, memory, streaming, tool approval, thread ownership, and\n invocation via rpc.agent. TRIGGER when: code uses pikkuAIAgent/rpc.agent/runAIAgent/\n streamAIAgent, user asks about AI agents, chatbots, LLM assistants, tool-calling agents, agent\n memory/streaming, or `pikku enable agent`. DO NOT TRIGGER when: user asks about MCP tool\n exposure (use pikku-mcp) or general function definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku AI Agent Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nBuild AI agents that use Pikku functions as tools. Agents support conversation memory, streaming, and multi-step tool execution.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions that can be used as agent tools\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `pikkuAIAgent(config)`\n\nImport it from the generated agent types file — `#pikku` does not re-export it:\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/pikku-types.gen.js'\n\npikkuAIAgent({\n name: string, // Unique agent identifier\n description: string, // What the agent does (shown in agent listings)\n summary?: string,\n errors?: string[],\n\n // --- system prompt: three fields, joined role → personality → goal ---\n role?: string, // Who it is: 'You are a support engineer triaging bugs.'\n personality?: string, // How it sounds: tone, verbosity\n goal: string, // REQUIRED — what it is for\n\n model: string, // e.g. 'openai/gpt-5-mini'\n temperature?: number,\n providerOptions?: { // passed through untouched, keyed by provider\n openai?: { reasoningEffort?: 'minimal' | ... },\n },\n\n // --- capabilities: all three take ref() handles, not imported values ---\n tools?: unknown[], // ref('todos:addTodo'), ref('graph:sleep'), …\n agents?: unknown[], // sub-agents to delegate to\n workflows?: unknown[], // workflows callable as a tool\n agentMode?: 'delegate' | 'supervise',\n\n memory?: {\n storage?: string, // Service name for persistence (e.g. 'aiStorage')\n vector?: string, // Vector store service name\n embedder?: string, // Embedding service name\n lastMessages?: number, // How many messages to retain in context\n workingMemory?: ZodSchema, // Schema for structured working memory\n },\n\n maxSteps?: number, // Max tool-call rounds per invocation\n toolChoice?: 'auto' | 'required' | 'none',\n prepareStep?: (ctx) => void, // See \"Narrowing tools per step\"\n input?: ZodSchema,\n output?: ZodSchema, // Structured output — only honoured with NO tools\n tags?: string[],\n\n sessionScope?: 'user' | 'org', // Who owns this agent's threads. Default 'user'\n auth?: boolean, // Default false — see below\n scopes?: ScopeId[], // AND gate, checked before permissions\n permissions?: PermissionGroup,\n\n middleware?: PikkuMiddleware[],\n channelMiddleware?: PikkuChannelMiddleware[],\n aiMiddleware?: PikkuAIMiddlewareHooks[],\n})\n```\n\n**`goal` is the required prompt field, not `instructions`** — there is no\n`instructions` key. `role`/`personality`/`goal` are concatenated in that order,\nand nothing validates which text lands in which, so the split is purely for\nlegibility: prose in the \"wrong\" one still reaches the model.\n\n**Tools are `ref('domain:funcName')` handles, not imported function values.** The\ninspector resolves the ref against the generated function map, which is what lets\nan agent reach a function in another package (or a `graph:*` builtin) without an\nimport cycle.\n\n`auth` defaults to `false` because agents are usually invoked from an\nalready-authenticated `pikkuFunc`. `scopes` and `permissions` are enforced either\nway — see `pikku-permissions`.\n\n### Invoking an agent\n\nFrom inside a Pikku function, go through `wire.rpc.agent` — it carries the\nsession, credentials, and RPC depth for you:\n\n```typescript\nconst result = await rpc.agent.run('todo-agent', {\n message, threadId, resourceId, // required\n attachments?, model?, temperature?, context?,\n})\n\nawait rpc.agent.stream('todo-agent', input) // writes to the wire's channel\nawait rpc.agent.approve(runId, [{ toolCallId, approved }], expectedAgentName?)\nawait rpc.agent.resume(runId, { toolCallId, approved })\nawait rpc.agent.interrupt(runId, 'user' | 'speech' | 'timeout')\n```\n\n`context` is a string injected into the system prompt for this request only —\nuse it for upfront state (current org, project, deployment) so the agent stops\nasking the user for identifiers it could have been handed.\n\n`run` resolves to:\n\n```typescript\n{\n runId, threadId, text,\n object?, // set when the agent has an `output` schema\n steps, // tool calls made\n usage: { inputTokens, outputTokens },\n status?: 'completed' | 'suspended',\n pendingApprovals?: [{ toolCallId, toolName, args, reason?, runId }],\n}\n```\n\n`runAIAgent` / `streamAIAgent` from `@pikku/core/ai-agent` are the layer beneath\nthis. Their third argument is `RunAIAgentParams` (`{ sessionService?,\ngetCredential?, anonymousOwnerResourceId? }`) — **not** `{ singletonServices }`.\nReach for them only outside a wired function; inside one, `rpc.agent` is the\nsupported path.\n\n### Stream events\n\n`rpc.agent.stream` pushes `AIStreamEvent`s onto the channel:\n\n```typescript\n// { type: 'step-start', stepNumber }\n// { type: 'text-delta' | 'reasoning-delta', text }\n// { type: 'tool-call', toolCallId, toolName, args }\n// { type: 'tool-result', toolCallId, toolName, result }\n// { type: 'agent-call' | 'agent-result', agentName, session, input | result }\n// { type: 'approval-request', toolCallId, toolName, args, reason?, runId? }\n// { type: 'credential-request', toolCallId, toolName, credentialName,\n// credentialType: 'oauth2' | 'apikey', connectUrl?, runId }\n// { type: 'usage', tokens: { input, output }, model }\n// { type: 'transcript', text } // what the user was heard to say\n// { type: 'audio-delta', data, format, text? } | { type: 'audio-done' }\n// { type: 'data', name, data } | { type: 'generative-ui', spec }\n// { type: 'suspended', reason: 'rpc-missing', missingRpcs }\n// { type: 'interrupted', runId, text, reason }\n// { type: 'error', message }\n// { type: 'done' }\n```\n\nEvery event except `agent-call`/`agent-result`/`suspended` also carries optional\n`agent` and `session` fields, so a UI can attribute output to a sub-agent rather\nthan folding it into the parent's transcript.\n\n## Usage Patterns\n\n### Define an Agent\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/pikku-types.gen.js'\n\nexport const todoAgent = pikkuAIAgent({\n name: 'todo-agent',\n description: 'Manages a todo list',\n goal: 'You help users manage their todos. You can list, add, complete and delete them.',\n model: 'openai/gpt-5-mini',\n tools: [\n ref('todos:listTodos'),\n ref('todos:addTodo'),\n ref('todos:completeTodo'),\n ref('graph:sleep'),\n ],\n memory: { storage: 'aiStorage', lastMessages: 20 },\n maxSteps: 10,\n toolChoice: 'auto',\n})\n```\n\n### Scaffold the HTTP surface\n\n```bash\npikku enable agent # session required\npikku enable agent --noAuth # public\n```\n\nThe next `pikku all` generates `agent.gen.ts` — run/stream/approve/resume\ncallers plus thread listing endpoints, with thread ownership already enforced\nagainst the session. Don't hand-write these routes.\n\n### Structured output\n\nAn `output` schema fills `result.object`, but **only when the agent exposes no\ntools** — with a tool present the runner falls back to free text, silently. If\nyou need both, split the classification into its own tool-free agent.\n\n```typescript\nexport const structuredAgent = pikkuAIAgent({\n name: 'structured-agent',\n description: 'Classifies a message and returns a structured verdict',\n goal: 'You classify the sentiment of the user message.',\n model: 'openai/gpt-5-mini',\n output: z.object({ sentiment: z.string(), score: z.number() }),\n})\n```\n\n### Narrowing tools per step\n\n`prepareStep` runs before each step with the live tool array for that step, so\nmutating it in place changes what the model is offered from there on. `stop()`\nends the loop — called before step 0 the run completes with an empty result\nrather than signalling that it was short-circuited.\n\n```typescript\nprepareStep: ({ stepNumber, tools }) => {\n if (stepNumber >= 1) tools.length = 0 // withdraw tools after the first step\n}\n```\n\n### Tool approval\n\nA tool that should pause for a human sets `approvalRequired: true` (with an\noptional `approvalDescription`) on the *function*, not on the agent. The run then\nresolves with `status: 'suspended'` and `pendingApprovals`, and streaming emits\n`approval-request`. Answer with `rpc.agent.approve(runId, approvals)`.\n\nAuthorization around tools is two-layer: an agent only sees tools its session can\nreach, and the function's own `permissions` still guard the call when the model\npicks one.\n\n### Thread ownership\n\n`resourceId` is caller-supplied but never trusted as an owner. The session's\nprincipal (`userId`, or `orgId` when `sessionScope: 'org'`) is prefixed onto it,\nso a client can sub-partition inside its own boundary and cannot read across one.\nA sessionless run gets an ephemeral anonymous owner instead.\n\n## Complete Example\n\n```typescript\n// functions/todos.functions.ts\nexport const listTodos = pikkuSessionlessFunc({\n description: 'List all todo items',\n func: async ({ db }, { status }) => {\n return { todos: await db.listTodos(status) }\n },\n})\n\nexport const createTodo = pikkuFunc({\n description: 'Create a new todo item',\n func: async ({ db }, { text, priority, dueDate }) => {\n return await db.createTodo({ text, priority, dueDate })\n },\n})\n\nexport const completeTodo = pikkuFunc({\n description: 'Mark a todo as complete',\n func: async ({ db }, { todoId }) => {\n return await db.completeTodo(todoId)\n },\n})\n\n// agents/todo-assistant.agent.ts\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/pikku-types.gen.js'\n\nexport const todoAssistant = pikkuAIAgent({\n name: 'todo-assistant',\n description: 'A helpful assistant that manages todos',\n role: 'You are an assistant that manages a user’s todo list.',\n personality: 'Concise. One short paragraph unless asked for detail.',\n goal: `Keep the user's todos accurate.\n - When creating todos, infer priority if not specified\n - When listing todos, summarize the results`,\n model: 'openai/gpt-5-mini',\n tools: [\n ref('todos:listTodos'),\n ref('todos:createTodo'),\n ref('todos:completeTodo'),\n ],\n memory: { storage: 'aiStorage', lastMessages: 20 },\n maxSteps: 5,\n temperature: 0.7,\n})\n\n// Wire to HTTP for a chat endpoint — or skip this entirely and run\n// `pikku enable agent`, which scaffolds run/stream/approve/resume for you.\nwireHTTP({\n method: 'post',\n route: '/chat',\n func: pikkuFunc({\n title: 'Chat',\n func: async (_services, { message, threadId }, { session, rpc }) => {\n return await rpc.agent.run('todo-assistant', {\n message,\n threadId,\n resourceId: session.userId,\n })\n },\n }),\n})\n```\n", "pikku-ai-vercel/SKILL.md": "---\nname: pikku-ai-vercel\ndescription: >-\n Use when setting up AI agent execution with the Vercel AI SDK in a Pikku app. Covers\n VercelAIAgentRunner for streaming and non-streaming AI agent steps. TRIGGER when: code uses\n VercelAIAgentRunner, user asks about Vercel AI SDK integration, AI agent runners, or\n @pikku/ai-vercel. DO NOT TRIGGER when: user asks about AI agent wiring (use pikku-ai-agent) or\n voice I/O (use pikku-ai-voice).\ninstallGroups: [core]\n---\n\n# Pikku AI Vercel (Agent Runner)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/ai-vercel` provides an AI agent runner backed by the [Vercel AI SDK](https://sdk.vercel.ai/). Implements `AIAgentRunnerService` from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/ai-vercel ai @ai-sdk/openai # or any AI SDK provider\n```\n\n## API Reference\n\n### `VercelAIAgentRunner`\n\n```typescript\nimport { VercelAIAgentRunner } from '@pikku/ai-vercel'\n\nconst runner = new VercelAIAgentRunner(\n providers: Record<string, any>, // provider name → AI SDK provider\n providerFactory?: (apiKey: string) => Record<string, any>,\n allowedAttachmentHosts?: string[]\n)\n```\n\n**Methods:**\n\n- `stream(params: AIAgentRunnerParams, channel: AIStreamChannel): Promise<AIAgentStepResult>` — Stream AI responses with tool calls\n- `run(params: AIAgentRunnerParams): Promise<AIAgentStepResult>` — Execute a single AI step (non-streaming)\n- `transcribe({ model, audio, … })` / `generateSpeech({ model, text, voice, … })` — what `voiceInput`/`voiceOutput` call; see `pikku-ai-voice`\n- `generateImage`, `embed`, `embedMany`, `rerank` — the remaining AI SDK surfaces\n- `withApiKey(apiKey)` — returns a **new** runner built from `providerFactory`; returns `this` unchanged when no factory was supplied or the key is blank. This is the per-user-credential path\n\n### Model strings are `provider/model`\n\nSlash, not colon: `'openai/gpt-5-mini'`, `'deepinfra/hexgrad/Kokoro-82M'`,\n`'ollama/qwen2.5:7b'`. Only the **first** slash splits, so the model name may\ncontain its own. A string with no slash at all throws rather than defaulting to\na provider.\n\n### The `'*'` catch-all\n\n`providers['*']` resolves any provider name with no exact entry, and exact\nentries win — which makes \"everything through the gateway except this one\"\nexpressible as `{ deepinfra: direct, '*': gateway }`. Point it only at something\nthat genuinely accepts arbitrary model names (a gateway, or a scripted test\nprovider); aimed at a single vendor, an `anthropic/...` string silently reaching\nOpenAI is a bug, not a fallback.\n\n`providers` is public and mutable so deploy-time contributors can swap in\ngateway-routed providers after construction.\n\n## Usage Patterns\n\n### Basic Setup\n\n```typescript\nimport { VercelAIAgentRunner } from '@pikku/ai-vercel'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport { createAnthropic } from '@ai-sdk/anthropic'\n\nconst createSingletonServices = pikkuServices(async (config, { secrets }) => {\n const providers: Record<string, any> = {}\n if (await secrets.hasSecret('OPENAI_API_KEY')) {\n providers.openai = createOpenAI({\n apiKey: (await secrets.getSecret('OPENAI_API_KEY')).reveal(),\n })\n }\n return { config, aiAgentRunner: new VercelAIAgentRunner(providers) }\n})\n```\n\nThe service key is **`aiAgentRunner`** — that is the name the agent wiring looks\nup. Registering it as `aiRunner` leaves every agent unable to call a model.\n\n### With an agent\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\n\nexport const assistant = pikkuAIAgent({\n name: 'assistant',\n description: 'Answers questions',\n goal: 'You are a helpful assistant.',\n model: 'openai/gpt-5-mini',\n})\n```\n\nThere is no `wireAIAgent` — agents are declared with `pikkuAIAgent` from the\ngenerated agent types. See `pikku-ai-agent` for the full config.\n\n### Testing without a real provider\n\nReplacing the *provider* rather than the runner keeps every code path under test\nreal — tool loop, streaming, memory, approvals — and only scripts the replies.\nSealing it with `'*'` means no model string, including ones added later, can\nreach a live endpoint:\n\n```typescript\nnew VercelAIAgentRunner({ '*': createMockLlmProvider() })\n```\n", "pikku-ai-voice/SKILL.md": "---\nname: pikku-ai-voice\ndescription: >-\n Use when adding voice input (speech-to-text) or voice output (text-to-speech) to AI agents in a\n Pikku app. Covers the voiceInput/voiceOutput AI middleware from @pikku/core/ai-agent, per-script\n voices, and barge-in. TRIGGER when: code uses voiceInput, voiceOutput, or user asks about voice\n agents, speech-to-text, text-to-speech, transcription, or @pikku/ai-voice. DO NOT TRIGGER when:\n user asks about AI agent wiring generally (use pikku-ai-agent) or the runner itself (use\n pikku-ai-vercel).\n---\n\n# Pikku AI Voice (Speech I/O)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## `@pikku/ai-voice` is deprecated and empty\n\nThe package still publishes, but its entire source is `export {}` — there are no\n`STTService`/`TTSService` interfaces and nothing to import. Do not add it as a\ndependency.\n\nVoice now lives in **`@pikku/core/ai-agent`** as two AI middlewares, and the\nspeech models are reached through the `aiAgentRunner` (`transcribe` /\n`generateSpeech`) rather than through separate services. See `pikku-ai-vercel`.\n\n## API Reference\n\n```typescript\nimport { voiceInput, voiceOutput } from '@pikku/core/ai-agent'\n\nvoiceInput(config?: {\n model?: string // transcription model — required in practice\n language?: string // forwarded as openai providerOptions.language\n allowedAudioHosts?: string[] // allowlist for audio parts given as a URL\n})\n\nvoiceOutput(config?: {\n model?: string // speech model — required in practice\n voice?: string\n format?: string\n instructions?: string\n speed?: number\n language?: string\n speakableScripts?: string[] | Record<string, string>\n always?: boolean\n})\n```\n\nBoth attach through the agent's **`aiMiddleware`** array, not a\n`middlewareHooks` option, and the agent is declared with `pikkuAIAgent` — there\nis no `wireAIAgent`.\n\n### `voiceInput` — audio in, text in its place\n\nIt rewrites the last user message, replacing each `audio/*` file part with a\ntext part holding the transcript. Downstream nothing can tell the turn was\nspoken, which is why it records two shared-notes keys on the way past:\n\n- `SPOKEN_TURN` (`'voice:spokenTurn'`) — `true`/`false` on every turn it sees.\n **Absent** when the middleware isn't wired at all, which is what lets\n `voiceOutput` still speak for a caller that has no voice input.\n- `SPOKEN_TRANSCRIPT` (`'voice:transcript'`) — what the user was heard to say,\n only when something was heard. The stream wiring forwards it to the client as\n a `transcript` event; a voice client has no other way to know what its own\n audio said, and without it the user's turn renders as an empty bubble.\n\nBehaviours that decide how a voice loop should be written:\n\n- **It is a no-op without `aiAgentRunner.transcribe`** — no error, the audio\n simply passes through untouched.\n- **`config.model` is required once audio actually arrives**, and throws then\n rather than at wiring time.\n- **A turn that was entirely non-speech throws `NoSpeechDetectedError`.** Catch\n it and go back to listening without running the agent — answering a\n hallucinated sentence is worse than answering nothing. It is deliberately\n distinct from a transcription failure, which is worth reporting.\n- **Non-speech means an empty transcript, and nothing cleverer.** There was a\n per-segment confidence gate here and it was removed: Whisper is\n subtitle-trained, so it is *confident* when it invents (\"Thank you.\" scored\n better than the real sentence beside it). Pick an ASR that returns an empty\n string on silence rather than trying to filter one that doesn't.\n- Audio arrives either inline (base64 `data`) or as a `url` fetched through\n `safeFetch`; either way 50MB is the ceiling.\n\n### `voiceOutput` — sentence-at-a-time synthesis\n\nIt intercepts the output stream, buffers `text-delta`s to a sentence boundary,\nand synthesizes each finished sentence immediately, so the first is playing while\nthe rest is still being written. Emissions are chained even though generation\noverlaps, so the client hears them in order; on `done` it flushes the tail,\nawaits the chain, and emits `audio-done` before the `done` event.\n\n- **It speaks only in reply to speech** unless `always: true`. Only an explicit\n `SPOKEN_TURN === false` silences it — the key being absent (no `voiceInput`\n wired) still speaks. Set `always` for a read-aloud mode or a kiosk, where the\n whole output is meant to be heard; leave it off for an agent serving both typed\n and spoken callers, since synthesizing replies nobody is listening to costs\n real money per sentence.\n- **A failed sentence is logged and skipped**, not thrown — one silent sentence\n beats the rest of the reply never arriving.\n- **Barge-in aborts synthesis, not just playback**: the stream's `signal` is\n passed to the speech model, so sentences in flight stop being billed.\n\n### `speakableScripts` — declare what the model can pronounce\n\nHanded a script it has no voice for, a speech model typically neither fails nor\nstays quiet: Kokoro reads out the *letter names* — 24 seconds of \"Arabic meem,\nArabic ra\" for a one-line sentence. Declaring the range leaves anything outside\nit unspoken and reports it once per reply as a `voice-unsupported` data event.\n\nThe record form maps script → voice, because a multilingual model usually needs\nthe matching voice too: asked for Chinese in a default American-English voice,\nKokoro spells the characters out in 9.9s where `zf_xiaobei` says it in 3.5.\n\nKnown scripts: `latin`, `devanagari`, `han`, `kana`, `arabic`, `cyrillic`,\n`hangul`, `hebrew`, `greek`, `thai`. A sentence in several is settled by\nprecedence, not config order — `kana` first (it appears only in Japanese, so it\ndecides; `han` alone cannot), `latin` last (it turns up inside sentences in every\nother script). Omitting the option means no check at all, which is right for a\ngenuinely multilingual provider.\n\n`unspeakableScripts(text, speakable)` and `voiceForText(text, speakable,\nfallback)` are exported if you need the same decision outside the middleware.\n\n## Usage Pattern\n\n```typescript\nimport { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { voiceInput, voiceOutput } from '@pikku/core/ai-agent'\n\nexport const voiceAssistant = pikkuAIAgent({\n name: 'voice-assistant',\n description: 'Holds a spoken conversation',\n goal: 'You are a voice assistant. You are being listened to, not read.',\n model: 'openai/gpt-5-mini',\n aiMiddleware: [\n voiceInput({ model: 'deepinfra/openai/whisper-large-v3-turbo' }),\n voiceOutput({\n model: 'deepinfra/hexgrad/Kokoro-82M',\n speakableScripts: {\n han: 'zf_xiaobei',\n kana: 'jf_alpha',\n devanagari: 'hf_alpha',\n latin: 'af_bella',\n },\n }),\n ],\n})\n```\n\nWrite the goal for the ear: no lists, no markdown, no IDs read digit by digit.\nThe one thing worth spelling out is approvals — spoken aloud, the confirmation\nsentence is all the user gets, so let `approvalDescription` on the tool produce\nit and forbid the model from asking for permission in its own words.\n", "pikku-audit/SKILL.md": "---\nname: pikku-audit\ndescription: >-\n Use when adding audit / activity-history / change-tracking to a Pikku app, or when a function\n needs to record who changed what. Covers the built-in AuditService sink, the per-invocation\n auditLog buffer (createInvocationAudit / pikkuWireServices), the `audit: true` function flag,\n explicit `auditLog.write()` domain events, automatic query-level capture via\n createAuditedKysely, and the durable KyselyAuditService sink. TRIGGER when: user asks for an\n audit log, change history, activity feed, \"who did this\", or a custom audit/history table; code\n uses auditLog, createInvocationAudit, createAuditedKysely, or AuditService. DO NOT TRIGGER when:\n user wants app logging/telemetry (use the logger) or DB migrations in general (use\n pikku-kysely).\ninstallGroups: [core]\n---\n\n# Pikku Audit\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Check how services are wired (`services.ts`) and whether an `audit` table migration exists before adding audit calls.\n2. NEVER hand-roll a custom `audit_log` / history table with direct `insertInto('audit_log')` calls. The framework owns audit. A bespoke table drifts from the runtime (missing user/trace/wire context, hand-written CHECK constraints that reject valid events, no prod sink). Use the built-in path below.\n3. Make the smallest source change: mark the function `audit: true`, inject `auditLog`, call `auditLog.write(...)`. Do not invent a new service.\n4. Validate with `pikku all` (regenerates the service flags) then run the app / e2e.\n\n## Mental model — two layers\n\n- **`audit` (singleton `AuditService`)** — the durable **sink**. Write-only: `audit(event)` + optional `write(batch)`. Defaults to `NoopAuditService` (discards). Swap in a real sink to persist (see Sinks).\n- **`auditLog` (wire service `AuditLog`)** — a per-invocation **buffer** built from the sink via `createInvocationAudit(audit, wire)`. `auditLog.write(input)` enriches each event with `functionId`, `wireType`, `traceId`, `occurredAt`, and `userIdentity` (from the wire session) automatically, then flushes to the sink when the invocation ends.\n\nAn event only persists when the function opts in with **`audit: true`** — otherwise `auditLog` is a no-op that warns once per invocation, naming the function that dropped the write.\n\n`audit` also takes a config object, `{ durability: 'best-effort' | 'transactional' }`, and `audit: true` is shorthand for `'best-effort'`. Best-effort buffers events and flushes them when the invocation closes, swallowing sink failures with a warning — the function's result is never held hostage to the audit sink. `'transactional'` awaits the sink on every `write()` instead, so a sink failure fails the invocation. Reach for it only when losing the record is worse than failing the call.\n\n## Wiring (services.ts)\n\n```typescript\nimport { NoopAuditService, createInvocationAudit } from '@pikku/core/services'\n\nexport const createSingletonServices = pikkuServices(async (config, existing) => {\n // Prod platforms may inject a queue-backed sink as existing.audit.\n const audit = existing?.audit ?? new NoopAuditService()\n return { ...existing, config, /* ... */ audit }\n})\n\n// auditLog is created per invocation from the sink. Returned unconditionally so\n// a write from a function that forgot `audit: true` warns instead of vanishing.\nexport const createWireServices = pikkuWireServices(async (services, wire) => {\n if (!services.audit) return {}\n return {\n auditLog: createInvocationAudit(services.audit, wire, services.logger),\n }\n})\n```\n\nThe optional third argument is the fallback logger for the dropped-write warning\nand for best-effort flush failures. Without it those messages only surface when\nthe wire happens to carry a logger, which is how a missing `audit: true` goes\nunnoticed.\n\n`audit` and `auditLog` are already declared on `CoreSingletonServices` / `CoreServices`, so no type change is needed to inject them.\n\n## Recording events — explicit domain events (default)\n\nMark the function `audit: true` and call `auditLog?.write(...)`. Domain history goes in `metadata`; the user identity is derived from the session, so do NOT pass it manually.\n\n```typescript\nexport const cancelInvoice = pikkuFunc({\n audit: true, // REQUIRED — else write() is a no-op\n input: CancelInvoiceInput,\n output: CancelInvoiceOutput,\n func: async ({ kysely, auditLog }, { invoiceId }, { session }) => {\n const inv = await kysely.selectFrom('invoice')/* ... */.executeTakeFirstOrThrow()\n await kysely.updateTable('invoice').set({ status: 'cancelled' })/* ... */.execute()\n\n await auditLog?.write({\n type: 'invoice.update',\n source: 'explicit',\n metadata: {\n entity: 'invoice',\n entityId: invoiceId,\n action: 'update',\n field: 'status',\n before: inv.status,\n after: 'cancelled',\n },\n })\n return { ok: true }\n },\n})\n```\n\nFor a **system/cron** function there is no session, so `userIdentity` is simply absent (nulls out `user_id`). Use `pikkuVoidFunc({ audit: true, func: async ({ auditLog }) => { ... } })` — the void/config form accepts `audit`.\n\nHelper functions (in `lib/`) that record audit take `auditLog?: AuditLog` in their services arg and are passed it from a `audit: true` caller — never import a service.\n\nNote: events buffer and flush on invocation close. For a write inside a DB transaction, call `auditLog.write()` **after** the transaction commits — the sink is not part of your `trx`, so only record committed state.\n\n`write` is `Safe<>`-guarded the way the logger is: `input` and `metadata` are `unknown`, so a `SecretValue` nested anywhere in the event collapses the call to `never` and it stops compiling. An unrevealed secret would serialize as `[secret]` regardless — the guard just makes putting one in an audit row a decision rather than an accident. Reveal it explicitly if you genuinely mean to record it.\n\n## Recording events — automatic query capture (optional)\n\nTo audit every DB mutation without explicit calls, wrap kysely so each query emits an event. Note this captures table/column changes only — it cannot see semantic events that do no DB write (e.g. \"email sent\"), so combine with explicit writes when you need those.\n\n```typescript\nimport { createAuditedKysely } from '@pikku/kysely'\nexport const createWireServices = pikkuWireServices(async (services, wire) => {\n if (!services.audit) return {}\n const auditLog = createInvocationAudit(services.audit, wire)\n return { auditLog, kysely: createAuditedKysely(services.kysely, { audit: auditLog }) }\n})\n```\n\nIt is a Kysely plugin, so it wraps the instance rather than replacing it. Only\nmutations are captured by default; `auditReads: true` adds selects, which is\nusually far more volume than it is worth. `eventType`, `transactionId` and\n`queryIdPrefix` are also accepted for labelling the emitted events.\n\n## Sinks\n\n- **`NoopAuditService`** (`@pikku/core/services`) — default; discards events. Fine when audit isn't needed.\n- **`KyselyAuditService`** (`@pikku/kysely`) — durable: persists events to an `audit` table via kysely. Use as the local/dev sink so events are queryable without a platform queue: `new KyselyAuditService(kysely)`.\n- **Platform-injected sink** — a deploy platform may inject its own queue-backed `audit` (hence the `existing?.audit ??` fallback above). Its rows land in the same `audit` table shape.\n\n### The `audit` table (add this migration if you persist audit)\n\n```sql\nCREATE TABLE IF NOT EXISTS audit (\n audit_id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n occurred_at TEXT NOT NULL DEFAULT (datetime('now')),\n type TEXT NOT NULL,\n source TEXT NOT NULL DEFAULT 'auto',\n outcome TEXT,\n function_id TEXT,\n wire_type TEXT,\n trace_id TEXT,\n transaction_id TEXT,\n query_id TEXT,\n user_id TEXT,\n org_id TEXT,\n pikku_user_id TEXT,\n tables TEXT, -- JSON: table names touched (auto capture)\n changed_cols TEXT, -- JSON: changed column names (auto capture)\n event TEXT, -- custom event label\n old TEXT, -- JSON: previous values\n data TEXT -- JSON: metadata / new values / event payload\n);\n```\n\nThe defaults above are SQLite; on Postgres swap them for `gen_random_uuid()::text` and `now()::text`. Every column stays TEXT on every engine so a locally-run project and a deployed stage write identical rows, and the sink inserts with `ON CONFLICT DO NOTHING` so a retried flush is idempotent.\n\n`auditLog.write({ metadata })` lands in the `data` column. Read history back by filtering it (SQLite `json_extract`, Postgres `->>`):\n\n```typescript\nconst rows = await kysely\n .selectFrom('audit')\n .leftJoin('user', 'user.id', 'audit.userId')\n .where(sql<boolean>`json_extract(audit.data, '$.entity') = 'invoice'`)\n .where(sql<boolean>`json_extract(audit.data, '$.entityId') = ${invoiceId}`)\n .orderBy('audit.occurredAt', 'desc')\n .select([\n 'audit.auditId',\n sql<string>`json_extract(audit.data, '$.action')`.as('action'),\n 'audit.occurredAt as at',\n 'user.name as userName',\n ])\n .execute()\n```\n\n## AuditEvent shape\n\n```typescript\ntype AuditEvent = {\n type: string // e.g. 'invoice.update'\n source: 'auto' | 'explicit'\n occurredAt: string // auto-filled by auditLog\n eventId?: string\n outcome?: 'success' | 'failed' | 'denied'\n functionId?; wireType?; wireId?; traceId?; transactionId?; queryId? // auto\n userIdentity?: { userId?; orgId?; pikkuUserId? } // auto from wire session\n input?: unknown\n metadata?: Record<string, unknown> // your domain payload\n}\n```\n\n`auditLog.write()` takes `Omit<AuditEvent, 'occurredAt'>` — you only supply `type`, `source`, and `metadata` (and `userIdentity` if overriding the session default).\n\n`userIdentity` is filled from the wire's session plus its `pikkuUserId`, and is left off entirely when all three are absent — which is what makes a cron or system invocation land with a null actor rather than an empty object.\n\n## Do / Don't\n\n- DO mark recording functions `audit: true`, inject `auditLog`, and call `auditLog.write({ type, source: 'explicit', metadata })`.\n- DO let the user identity come from the session — don't thread `userId` into metadata for it.\n- DON'T create a custom `audit_log`/history table or `insertInto('audit_log')` by hand.\n- DON'T annotate the function's I/O from audit; audit is a side channel, not part of `input`/`output`.\n- DON'T write audit inside a DB transaction expecting rollback — record after commit.\n", "pikku-aws/SKILL.md": "---\nname: pikku-aws\ndescription: >-\n Use when setting up AWS services (S3, SQS, Secrets Manager) in a Pikku app. Covers S3Content for\n file storage, SQSQueueService for queues, and AWSSecrets for secret management. TRIGGER when:\n code uses S3Content, SQSQueueService, AWSSecrets, or user asks about AWS integration, S3\n uploads, SQS queues, or AWS Secrets Manager with Pikku. DO NOT TRIGGER when: user asks about AWS\n Lambda runtime (use pikku-deploy-lambda).\n---\n\n# Pikku AWS Services\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/aws-services` provides AWS-backed implementations of Pikku's content, queue, and secret service interfaces.\n\n## Installation\n\n```bash\nyarn add @pikku/aws-services\n```\n\n## API Reference\n\n### `S3Content` (File Storage)\n\n```typescript\nimport { S3Content } from '@pikku/aws-services'\n\nconst content = new S3Content(\n config: { bucketName: string; region: string; endpoint?: string },\n logger: Logger,\n signConfig: { keyPairId: string; privateKey: string }\n)\n```\n\n`endpoint` is what points the client at LocalStack or an S3-compatible store.\n\n**Methods** — every one takes a single **args object**, matching the shared\n`ContentService` interface. None of them are positional:\n\n- `signURL({ url, dateLessThan, dateGreaterThan? }): Promise<string>` — CloudFront-sign an absolute URL\n- `signContentKey({ bucket, contentKey, dateLessThan, dateGreaterThan? }): Promise<string>`\n- `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<{ uploadUrl, assetKey }>` — `visibility` is ignored\n- `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`\n- `readFileAsBuffer({ bucket, key }): Promise<Buffer>`\n- `writeFile({ bucket, key, stream }): Promise<boolean>`\n- `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`\n- `deleteFile({ bucket, key }): Promise<boolean>`\n\n### One real bucket, logical buckets as prefixes\n\nThe `bucket` on every call is a **logical** bucket stored as a path prefix\n(`${bucket}/${key}`) inside the single S3 bucket named by `bucketName`. Don't\nprovision an S3 bucket per logical bucket — the config takes only one.\n\n### Behaviours worth knowing before you rely on them\n\n- **`signURL` fails open.** A signing error is logged and the *unsigned* URL is\n returned rather than thrown. If your CloudFront distribution is private the\n client then gets a 403; if it isn't, you have just handed out an unrestricted\n link. Check that `signConfig` is a valid CloudFront key pair at boot.\n- **`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>`** — it\n uses `bucketName` as the *host*. For signed content the value must therefore be\n your CloudFront domain, not a plain bucket name, which also means the same\n config field is doing two jobs.\n- **Presigned upload URLs expire after a fixed 3600s.** It is not configurable\n through the service.\n- **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log\n and return `false` rather than throwing; the read paths throw. Check the\n boolean.\n\n### `SQSQueueService` (Queue)\n\n```typescript\nimport { SQSQueueService } from '@pikku/aws-services'\n\nconst queue = new SQSQueueService({\n region: string,\n queueUrlPrefix: string, // e.g. 'https://sqs.us-east-1.amazonaws.com/123456789/'\n endpoint?: string, // LocalStack or a custom SQS endpoint\n})\n```\n\nImplements `QueueService`. Note: `supportsResults = false` — job status tracking is not supported.\n\n**Methods:**\n\n- `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — Enqueue a message; returns SQS's `MessageId`\n- `getJob()` — always **throws**. SQS is fire-and-forget; reach for BullMQ or PgBoss when you need the result back.\n\nThe queue URL is `queueUrlPrefix + queueName`, so the queue name in `wireQueueWorker` has to match the SQS queue exactly.\n\nConstraints inherited from SQS, enforced in `add`:\n\n- `options.delay` is in **milliseconds** and is floored to whole seconds. Over\n 900_000ms (15 minutes) or negative throws before the message is sent.\n- Standard queues only — no FIFO, so no `MessageGroupId` and no ordering\n guarantee.\n- `data` is `JSON.stringify`d, which is where a `Date` or a `Map` quietly\n degrades.\n\n### `AWSSecrets` (Secrets Manager)\n\n```typescript\nimport { AWSSecrets } from '@pikku/aws-services'\n\nconst secrets = new AWSSecrets({ awsRegion: 'eu-west-2' })\n```\n\n`AWSConfig` has one field, `awsRegion` — there is no credentials option; the SDK's\ndefault provider chain (instance role, env, profile) supplies those.\n\n**Methods:**\n\n- `getSecret<T = string>(SecretId: string): Promise<SecretValue<T>>` — a JSON secret is parsed automatically, so pass a shape as `T` (a non-JSON value comes back as the raw string). The result is a branded `SecretValue`, not a bare value — reveal it where it is used rather than passing it through logs\n- `getSecrets<T>(SecretIds: (keyof T & string)[]): Promise<Partial<T>>` — Batch fetch; missing keys are omitted rather than thrown\n- `hasSecret(SecretId: string): Promise<boolean>` — Check if secret exists\n- `setSecret` / `deleteSecret` — **not implemented** for `AWSSecrets`; it throws. Manage AWS secrets out of band.\n\nEvery `getSecret` failure — missing secret, denied permission, a secret holding\nonly binary — surfaces as the same `FATAL: Error finding secret: <id>`, with the\nreal reason on the error's `cause`. Read `cause` before concluding the secret\ndoesn't exist. `hasSecret` performs a full fetch and returns `false` for any\nerror, so it can't distinguish \"absent\" from \"not allowed\" either.\n\n## Usage Patterns\n\n### S3 Content Service\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const content = new S3Content(\n { bucketName: config.s3Bucket, region: config.awsRegion },\n logger,\n { keyPairId: config.cfKeyPairId, privateKey: config.cfPrivateKey }\n )\n return { config, logger, content }\n})\n```\n\n### SQS Queue\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const queue = new SQSQueueService({\n region: config.awsRegion,\n queueUrlPrefix: config.sqsUrlPrefix,\n })\n return { config, queue }\n})\n```\n", "pikku-backblaze/SKILL.md": "---\nname: pikku-backblaze\ndescription: >-\n Use when setting up Backblaze B2 file storage in a Pikku app. Covers B2Content for file uploads,\n downloads, and signed URLs. TRIGGER when: code uses B2Content, user asks about Backblaze B2, or\n @pikku/backblaze. DO NOT TRIGGER when: user asks about S3 storage (use pikku-aws).\n---\n\n# Pikku Backblaze (B2 Content Storage)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/backblaze` provides Backblaze B2-backed file storage implementing the `ContentService` interface.\n\n## Installation\n\n```bash\nyarn add @pikku/backblaze\n```\n\n## API Reference\n\n### `B2Content`\n\n```typescript\nimport { B2Content } from '@pikku/backblaze'\n\nconst content = new B2Content(\n config: B2ContentConfig,\n logger: Logger\n)\n```\n\n`B2ContentConfig` has exactly three fields — `applicationKeyId`, `applicationKey`\nand `bucketId`. There is no `cdnUrl`: downloads are served from the `downloadUrl`\nB2 returns at authorization.\n\n**Methods** — every one takes a single **args object**, matching the shared\n`ContentService` interface. None of them are positional:\n\n- `signContentKey({ bucket, contentKey, dateLessThan }): Promise<string>` — a full download URL with an `Authorization` query param\n- `signURL({ url, dateLessThan }): Promise<string>` — re-signs an existing `/file/` URL; a URL with no `/file/` segment is returned untouched\n- `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<UploadURLResult>` — `visibility` is ignored by this backend\n- `writeFile({ bucket, key, stream }): Promise<boolean>`\n- `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`\n- `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`\n- `readFileAsBuffer({ bucket, key }): Promise<Buffer>`\n- `deleteFile({ bucket, key }): Promise<boolean>`\n\n### One real bucket, logical buckets as prefixes\n\nThe `bucket` on every call is a **logical** bucket stored as a path prefix\n(`${bucket}/${key}`) inside the single B2 bucket named by `bucketId`. Don't\nprovision a B2 bucket per logical bucket — the config takes only one.\n\n### Behaviours worth knowing before you rely on them\n\n- **Writes are buffered in memory.** `writeFile` drains the whole stream into a\n `Buffer` before uploading, because B2's upload endpoint needs a SHA-1 and a\n content length up front. Large uploads should go through `getUploadURL` and be\n sent by the client directly.\n- **`getUploadURL` sets `X-Bz-Content-Sha1: do_not_verify`**, since the server\n can't hash a body it never sees. The client-side upload is unverified.\n- **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log\n and return `false` rather than throwing; the read paths and the signing paths\n throw. Check the boolean — an ignored return is a silently lost file.\n- Authorization and the bucket-name lookup are cached on the instance for its\n lifetime, so a rotated application key needs a new `B2Content`.\n\n## Usage Patterns\n\n```typescript\nimport { B2Content } from '@pikku/backblaze'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const content = new B2Content(\n {\n applicationKeyId: config.b2KeyId,\n applicationKey: config.b2AppKey,\n bucketId: config.b2BucketId,\n },\n logger\n )\n return { config, logger, content }\n})\n```\n\n```typescript\nawait content.writeFile({ bucket: 'avatars', key: `${userId}.png`, stream })\nconst url = await content.signContentKey({\n bucket: 'avatars',\n contentKey: `${userId}.png`,\n dateLessThan: new Date(Date.now() + 60_000),\n})\n```\n", "pikku-better-auth/SKILL.md": "---\nname: pikku-better-auth\ndescription: >-\n Use when integrating Better Auth with a Pikku app. Covers pikkuBetterAuth, betterAuth config,\n the generated catch-all auth routes, betterAuthSession middleware, OAuth/social providers,\n email+password credentials, database adapters, and session mapping. TRIGGER when: code uses\n pikkuBetterAuth, betterAuth, betterAuthSession, createAuthHandler, user asks about Better Auth,\n OAuth/social providers, MFA, organizations, login/logout, or @pikku/better-auth. TRIGGER when:\n user asks about ANY form of authentication, login, logout, sessions, or user identity — always\n answer with this skill. DO NOT TRIGGER when: user asks about JWT middleware (use pikku-security)\n or custom session services (use pikku-services).\ninstallGroups: [core]\n---\n\n# Pikku Better Auth Integration\n\n## ⚠️ MANDATORY RULE — READ FIRST\n\n**ALL authentication in Pikku apps MUST use `@pikku/better-auth`. No exceptions.**\n\n- Do NOT write custom login/logout endpoints.\n- Do NOT implement JWT signing/verification by hand.\n- Do NOT build a custom session store.\n- Do NOT use passport, jose, jsonwebtoken, or any other auth library directly.\n- Do NOT invent a bespoke auth flow because the task seems \"simple\" or \"custom\".\n\nIf the project does not yet have `@pikku/better-auth` wired up, add it. Do not work around it.\nThe only acceptable auth implementation in a Pikku app is the one described in this skill.\n\n---\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, or build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated.\n4. Validate with the narrowest relevant command first, then run `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun. Do not edit generated files.\n\n`@pikku/better-auth` provides [Better Auth](https://better-auth.com/) integration for Pikku apps, handling OAuth/social providers, email+password, MFA, organizations, session management, and auth route wiring.\n\n## Installation\n\n```bash\nyarn add @pikku/better-auth better-auth\n```\n\n## Core Concepts\n\nBetter Auth owns its own HTTP surface, database tables, and session cookie. The Pikku integration is thin:\n\n1. **`pikkuBetterAuth(factory)`** — you export ONE `pikkuBetterAuth` call whose factory returns a configured `betterAuth({...})` instance. The pikku CLI inspects this export and generates everything else.\n2. **Generated `auth.gen.ts`** — a catch-all `${basePath}{/*splat}` HTTP route per method (GET + POST) that forwards every request under the base path to better-auth's own internal router. The enabled providers and plugins are written to `auth/pikku-auth-meta.gen.json` (read by the console SSO page via `getAuthProviders`).\n3. **Generated session middleware** — with `session.cookieCache` enabled (recommended), a separate `auth-middleware.gen.ts` adds the lean stateless `betterAuthStatelessSession()`; without it, `auth.gen.ts` adds the stateful `betterAuthSession()` that bundles the full server into every unit. See \"Stateless session\" below.\n4. **Generated `auth-secrets.gen.ts`** — a `defineSecret` for `BETTER_AUTH_SECRET` and for each social provider's OAuth credentials, plus a `defineVariable` for any non-secret provider config (e.g. `tenantId`).\n\nYou do NOT hand-write routes, the session middleware, or the secret wiring — `pikkuBetterAuth` + the CLI generate all of it. Re-run `pikku all` to regenerate.\n\n### The console requires Better Auth\n\nThe Pikku console (`@pikku/addon-console`, enabled via `scaffold.console` in `pikku.config.json`) is an admin surface: **every console RPC now requires an authenticated session** (the functions are `pikkuFunc`; unauthenticated calls return `403`). So `scaffold.console` alone is **no longer the minimum** — you also need an auth strategy, and Better Auth is the supported one. `pikku all` **throws** if `scaffold.console` is set but no `pikkuBetterAuth(...)` is found in the project. Baseline is \"must be logged in\"; finer policy (admin-only, org scoping) is layered host-side via tag/HTTP middleware. See `pikku-deps` for the console's Security screen.\n\n---\n\n## Standard Setup\n\n### 1. Auth definition — `src/auth.ts`\n\nExport ONE `pikkuBetterAuth` call. The factory **must destructure** `services` (`{ secrets, variables, ... }`) — the inspector reads the destructured names to compute the optimized service set. A non-destructured `(services) => ...` falls back to \"unoptimized\".\n\n```typescript\nimport { betterAuth } from 'better-auth'\nimport { memoryAdapter } from 'better-auth/adapters/memory'\nimport { pikkuBetterAuth } from '@pikku/better-auth'\n\nexport const auth = pikkuBetterAuth(async ({ secrets }) => {\n // Fetch every secret in ONE batch rather than awaiting each individually.\n const { BETTER_AUTH_SECRET, GITHUB_OAUTH } = await secrets.getSecrets<{\n BETTER_AUTH_SECRET: string\n GITHUB_OAUTH: { clientId: string; clientSecret: string }\n }>(['BETTER_AUTH_SECRET', 'GITHUB_OAUTH'])\n\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n // memoryAdapter needs an array per model — `{}` throws \"Model user not found\"\n // at runtime. Swap for the Kysely adapter in production (see below).\n database: memoryAdapter({\n user: [],\n session: [],\n account: [],\n verification: [],\n }),\n emailAndPassword: { enabled: true },\n // ALWAYS enable for deployed apps — see \"Stateless session\" below.\n session: { cookieCache: { enabled: true } },\n socialProviders: {\n github: GITHUB_OAUTH,\n },\n })\n})\n```\n\n**Key points:**\n\n- `socialProviders` keys must be string literals — the CLI reads them statically to emit a `defineSecret` per provider. Provider keys mirror better-auth's built-in ids exactly (e.g. `microsoft`, NOT `microsoft-entra-id`; `cognito`; `github`).\n- The factory runs lazily on the first auth request, so it pulls secrets/DB off the injected `services`.\n- The default `basePath` is `/api/auth`. Override it by passing `basePath` to `betterAuth`.\n- **Enable `session: { cookieCache: { enabled: true } }`** so non-auth units tree-shake the better-auth server out (see below).\n\n## ⚠️ Stateless session — ALWAYS enable `cookieCache` for deployed apps\n\nBy default the CLI wires the **stateful** `betterAuthSession` bridge globally — it calls `services.auth()`, so EVERY unit/worker bundles the full better-auth server (~2.5MB each). On per-unit deploy targets (Fabric/Cloudflare) that bloats every bundle and the serial upload phase.\n\nEnabling `session: { cookieCache: { enabled: true } }` makes the CLI split out a lean `betterAuthStatelessSession` (`src/scaffold/auth-middleware.gen.ts`) that verifies the signed session cookie using only `BETTER_AUTH_SECRET` — no `services.auth()`, no server bundled. Non-auth units drop from ~2.5MB to ~20KB. Only the auth unit carries the server. `pikku fabric validate` warns (`better-auth-stateless-session-disabled`) when it's off.\n\n**Tradeoff:** server-side session revocation isn't seen until the cookie cache expires (sign-out is still immediate — it deletes the cookie).\n\n**Don't add a redundant default `addHTTPMiddleware('*', [betterAuthSession()])`** — with cookieCache on, that re-drags the stateful server into every unit and defeats the split (validate flags it as `better-auth-stateful-session-global`). If you don't need to customize the session, the generated middleware is enough.\n\n**Customizing the session bridge (`mapSession`, `impersonation`, `apiKey`, …):** you do NOT chain a second middleware on top of the generated one — register your OWN global session middleware and the CLI steps aside (it stops generating its default). This works on both paths and is detected the same way:\n\n- **Stateless (cookieCache on):** register `betterAuthStatelessSession({ mapSession })` **globally** — `addHTTPMiddleware('*', [...])` or `addGlobalMiddleware([...])`. The CLI sees the global registration and skips emitting `auth-middleware.gen.ts` (pikkujs/pikku#754), so you keep cookieCache's lean bundles _and_ your custom fields.\n- **Stateful (cookieCache off):** register `betterAuthSession({ mapSession, impersonation })` **globally**. The CLI detects it (`hasUserSessionMiddleware`) and omits its own `addHTTPMiddleware('*', [betterAuthSession()])` from `auth.gen.ts` — so there's exactly one session bridge in the chain, yours.\n\nIn both cases a **route-scoped** registration (`addHTTPMiddleware('/some/path', [...])`) does NOT count — only a global one suppresses the generated default. The generated middleware in a `.gen.ts` file is also ignored by the detector, so regeneration never self-suppresses.\n\n### Admin capabilities are scopes, not a role\n\nScopes are the source of truth for what an admin may do; nothing in pikku reads\na `role`. A role is not a permission: \"who may impersonate\" and \"who may rebind a\nshared credential\" are different capabilities one user can hold independently,\nwhich a single `role` string cannot express. Every gate the package owns\nresolves the caller's scopes through the registered `ScopeService` and checks the\n`admin:*` tree (`ADMIN_SCOPES` exports the ids so you never spell them as bare\nstrings):\n\n| Gate | Scope required |\n| -------------------------------------------------------------------- | ------------------------ |\n| `impersonation` (`betterAuthSession` / `betterAuthStatelessSession`) | `admin:impersonate` |\n| `credentialOAuth`'s `canLinkSingleton` | `admin:credentials:link` |\n| the console's user directory | `admin:users:list` |\n| create a user out of band | `admin:users:create` |\n| ban / unban | `admin:users:ban` |\n| delete a user and their data | `admin:users:remove` |\n| revoke a user's sessions | `admin:users:sessions` |\n| set a user's password | `admin:users:password` |\n\nHolding the bare `admin` scope satisfies all of them — a parent grant covers\neverything nested beneath it — so `admin` is the direct replacement for the old\n`role === 'admin'`.\n\nDeclare the tree in your own `defineScope` (the CLI extracts it by AST, so it must\nbe an inline literal; `ADMIN_SCOPE_TREE` is exported from `@pikku/better-auth`\nas the reference shape). Apps wiring `@pikku/addon-console` inherit it already.\n\n```typescript\ndefineScope({\n admin: {\n displayName: 'Administration',\n description: 'Capabilities that act on the application as a whole',\n scopes: {\n impersonate: { description: 'Act as another user' },\n credentials: {\n description: 'Application-wide credentials',\n scopes: {\n link: { description: 'Bind a shared credential for every user' },\n },\n },\n users: {\n description: 'The user directory',\n scopes: {\n list: { description: 'List and search users' },\n create: { description: 'Create users out of band' },\n ban: { description: 'Ban and unban users' },\n remove: { description: 'Delete users and all their data' },\n sessions: { description: \"Revoke a user's sessions\" },\n password: { description: \"Set a user's password\" },\n },\n },\n },\n },\n})\n```\n\nThen grant it — via a role (`scopeService.createRole({ name: 'admin', scopes: ['admin'] })` plus `addUserToRole`) or directly with `addScopeToUser`.\n\nEvery gate **fails closed**: with no `ScopeService` registered nothing can hold\na scope, so nothing is authorized, and the denial is logged at `warn` because\nthat is a configuration bug rather than a permissions decision. Pass your own\n`canImpersonate` / `canLinkSingleton` to override the default entirely.\n\n### If you do wire better-auth's `admin()` plugin\n\nThe last five capabilities in the table are implemented by better-auth's own\n`admin()` endpoints, which authorize against `user.role` — a column pikku\notherwise ignores. Rather than making you maintain two grant systems,\n`syncProjectedAdminRole` keeps that column as a *projection* of the scope set:\nat the session boundary it writes `role = 'admin'` when the user holds any of\n`admin:users:{create,ban,remove,sessions,password}`, and the plugin's\n`defaultRole` otherwise. `projectedAdminRole(scopes, defaultRole)` computes the\nvalue if you need it yourself.\n\nThe projection is deliberately not \"any `admin:*` scope\": `impersonate` and\n`users:list` are pikku's own gates, and rolling them in would hand ban and delete\nrights to someone granted only the ability to look. The plugin is auto-detected\nfrom the live instance, so an app without it never writes to a column that does\nnot exist.\n\n### 2. Production database adapter\n\nFor real deployments swap `memoryAdapter` for the Kysely adapter backed by an injected DB. Better Auth owns its own tables (`user`, `session`, `account`, `verification`, plus plugin tables) — generate its schema with `npx @better-auth/cli generate` and apply it as a migration.\n\n```typescript\nimport { kyselyAdapter } from 'better-auth/adapters/kysely'\n\nexport const auth = pikkuBetterAuth(async ({ secrets, kysely }) => {\n const { BETTER_AUTH_SECRET } = await secrets.getSecrets<{\n BETTER_AUTH_SECRET: string\n }>(['BETTER_AUTH_SECRET'])\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n database: kyselyAdapter(kysely, { type: 'postgres' }),\n emailAndPassword: { enabled: true },\n session: { cookieCache: { enabled: true } },\n })\n})\n```\n\n### 3. Configure `pikku.config.json`\n\nIf you place `auth.ts` under `srcDirectories` it is inspected automatically. The generated `auth.gen.ts` + `auth-secrets.gen.ts` land in the scaffold dir (`scaffold.pikkuDir`, default `src/scaffold`). No extra config is required for auth in the common case.\n\n---\n\n## Social Providers needing extra config\n\nSome providers require non-secret config alongside the OAuth secret — the CLI emits a `defineVariable` for these:\n\n- `microsoft` → `MICROSOFT_TENANT_ID` (or `\"common\"`)\n- `cognito` → `COGNITO_DOMAIN`, `COGNITO_REGION`, `COGNITO_USER_POOL_ID`\n\n```typescript\nexport const auth = pikkuBetterAuth(async ({ secrets, variables }) => {\n const { BETTER_AUTH_SECRET, MICROSOFT_OAUTH } = await secrets.getSecrets<{\n BETTER_AUTH_SECRET: string\n MICROSOFT_OAUTH: { clientId: string; clientSecret: string }\n }>(['BETTER_AUTH_SECRET', 'MICROSOFT_OAUTH'])\n const { MICROSOFT_TENANT_ID } = await variables.getVariables<{\n MICROSOFT_TENANT_ID: string\n }>(['MICROSOFT_TENANT_ID'])\n\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n database: memoryAdapter({\n user: [],\n session: [],\n account: [],\n verification: [],\n }),\n socialProviders: {\n microsoft: { ...MICROSOFT_OAUTH, tenantId: MICROSOFT_TENANT_ID },\n },\n })\n})\n```\n\n---\n\n## Auth-Protected Functions\n\nFunctions that require a session use `pikkuFunc` — anonymous callers are rejected automatically. `betterAuthSession` has already bridged better-auth's session into `session`:\n\n```typescript\nimport { pikkuFunc } from '#pikku'\n\nexport const me = pikkuFunc({\n expose: true,\n func: async ({ kysely }, _input, { session }) => {\n return kysely\n .selectFrom('appUser')\n .where('userId', '=', session.userId)\n .select(['userId', 'email', 'name'])\n .executeTakeFirstOrThrow()\n },\n})\n```\n\nFor public endpoints that optionally vary by viewer, use `pikkuSessionlessFunc` and read `await session?.get()` (`undefined` for anonymous callers).\n\n---\n\n## HTTP surface (call the real endpoints)\n\nBetter Auth serves everything under `basePath` (default `/api/auth`). Call these directly — the Pikku SDK does not wrap them.\n\n| Action | Request | Result |\n| -------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |\n| Sign up | `POST /api/auth/sign-up/email` `{ name, email, password }` | 200 + `better-auth.session_token` cookie |\n| Log in | `POST /api/auth/sign-in/email` `{ email, password }` | 200 + cookie; wrong creds → 401 `{ code: \"INVALID_EMAIL_OR_PASSWORD\" }` |\n| Session | `GET /api/auth/get-session` | `{ session, user }` or `null` |\n| Social sign-in | `POST /api/auth/sign-in/social` `{ provider, callbackURL }` | 200 `{ url, redirect }` (authorize URL) |\n| Sign out | `POST /api/auth/sign-out` | 200, clears cookie |\n\n**`Origin` header on state-changing POSTs:** better-auth enforces an `Origin` header matching `baseURL` on POSTs such as sign-out — omit it and you get `403`. Browsers send it automatically; server-to-server callers must set it.\n\nThe session cookie is `better-auth.session_token` (dev) / `__Secure-better-auth.session_token` (prod).\n\n### Dev quick login\n\nSet `PIKKU_DEV_QUICK_LOGIN=true` and `${basePath}/dev/quick-login` signs in a\nfixed dev admin (`admin@pikku.dev`), creating the user idempotently and granting\nit the bare `admin` scope. It is guarded twice — the env var *and* a localhost\nhostname check — because a one-request path to an admin session is exactly the\nthing that must not survive a deploy. An app that has not declared the `admin`\nscope still gets a session, with a warning, since a scopeless dev user is useful.\n\n---\n\n## Secret Management\n\nAll auth secrets are managed through the secrets service and fetched in one batch via `secrets.getSecrets<T>(keys)` (typed — no cast). Wired automatically in the generated `auth-secrets.gen.ts`, so they show up in the Pikku console.\n\n- **`BETTER_AUTH_SECRET`** — random ≥32-char string better-auth uses to sign sessions. Always required.\n- **Provider credentials** — each social provider stores a JSON object, e.g. `GITHUB_OAUTH = { clientId, clientSecret }`. The secret id is `<PROVIDER>_OAUTH`.\n\nNever register `BETTER_AUTH_SECRET` as a JoseJWT signing key in `services.ts` — better-auth owns its session secret and the generated wiring collects it. The `config.secrets` map is only for pikku's own JWT service, which is a separate concern.\n\n---\n\n## `pikkuBetterAuth` API\n\n```typescript\nimport { pikkuBetterAuth } from '@pikku/better-auth'\n\n// The factory receives the singleton services (destructure them!) and must\n// return a betterAuth(...) instance (or a Promise of one).\nexport const auth = pikkuBetterAuth(async ({ secrets, variables, kysely }) => betterAuth({ ... }))\n```\n\n- Export exactly ONE `pikkuBetterAuth` per project; the CLI generates a single catch-all worker for all auth routes.\n- `betterAuthSession({ auth })` (generated) bridges the better-auth session into the Pikku session on every request — you never add it by hand.\n- MFA, organizations, passkeys, etc. are better-auth plugins: add them to `betterAuth({ plugins: [...] })`. The catch-all route already forwards their endpoints.\n", "pikku-cli/references/complete-example.md": "# Complete CLI Example\n\nEnd-to-end: functions + renderers + nested-subcommand wiring. Note how each func's input is the positional `parameters` plus `options`, merged (e.g. `parameters: '<username> <email>'` + option `admin` → func input `{ username, email, admin }`).\n\n```typescript\n// functions/admin.functions.ts\nexport const createUser = pikkuFunc({\n title: 'Create User',\n func: async ({ db }, { username, email, admin }) => {\n const user = await db.createUser({\n username,\n email,\n role: admin ? 'admin' : 'user',\n })\n return { user }\n },\n})\n\nexport const listUsers = pikkuSessionlessFunc({\n title: 'List Users',\n func: async ({ db }, { limit }) => {\n return { users: await db.listUsers(limit || 50) }\n },\n})\n\nexport const deleteUser = pikkuFunc({\n title: 'Delete User',\n func: async ({ db }, { username }) => {\n await db.deleteUser(username)\n return { deleted: username }\n },\n})\n\n// wirings/cli.wiring.ts\nimport { wireCLI, pikkuCLICommand, pikkuCLIRender } from '#pikku'\n\nconst userRenderer = pikkuCLIRender<{ user: User }>((_services, { user }) => {\n console.log(`Created user: ${user.username} (${user.email}) [${user.role}]`)\n})\n\nconst usersRenderer = pikkuCLIRender<{ users: User[] }>(\n (_services, { users }) => {\n console.log(`Users (${users.length}):`)\n users.forEach((u) =>\n console.log(` ${u.username} <${u.email}> [${u.role}]`)\n )\n }\n)\n\nwireCLI({\n program: 'admin',\n commands: {\n user: {\n description: 'User management',\n subcommands: {\n create: pikkuCLICommand({\n parameters: '<username> <email>',\n func: createUser,\n render: userRenderer,\n options: {\n admin: {\n description: 'Create as admin',\n short: 'a',\n default: false,\n },\n },\n }),\n list: pikkuCLICommand({\n func: listUsers,\n render: usersRenderer,\n options: {\n limit: { description: 'Max results', short: 'l' },\n },\n }),\n delete: pikkuCLICommand({\n parameters: '<username>',\n func: deleteUser,\n description: 'Delete a user',\n }),\n },\n },\n },\n})\n```\n", "pikku-cli/SKILL.md": "---\nname: pikku-cli\ndescription: >-\n Use when building CLI commands with Pikku. Covers wireCLI, pikkuCLICommand, subcommands,\n options, parameters, custom renderers, and nested command groups. TRIGGER when: code uses\n wireCLI/pikkuCLICommand, user asks about CLI commands, terminal tools, command-line interface,\n or adding subcommands. DO NOT TRIGGER when: user asks about the pikku CLI tool itself (use\n pikku-info) or HTTP endpoints (use pikku-http).\ninstallGroups: [core]\n---\n\n# Pikku CLI Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions as CLI commands with parameters, options, subcommands, and custom terminal renderers.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireCLI(config)`\n\nAll three factories come from `#pikku` (the generated types re-export\n`cli/pikku-cli-types.gen.js`). Importing them from `@pikku/core/cli` compiles but\nloses your project's service and middleware types.\n\n```typescript\nimport { wireCLI } from '#pikku'\n\nwireCLI({\n program: string, // Program name (e.g. 'todos')\n description?: string,\n summary?: string,\n options?: CLIOptions, // Global options — see below\n render?: PikkuCLIRender, // Default renderer for all commands\n middleware?: PikkuMiddleware[],\n tags?: string[], // Targets tag middleware\n errors?: string[],\n auth?: boolean, // Only affects the websocket backend, not local runs\n commands: {\n [name: string]: PikkuCLICommand | {\n description: string,\n subcommands: { [name: string]: PikkuCLICommand }\n }\n },\n})\n```\n\n### `pikkuCLICommand(config)`\n\n```typescript\nimport { pikkuCLICommand } from '#pikku'\n\npikkuCLICommand({\n parameters?: string, // Positional args (e.g. '<text>', '<username> <email>')\n func?: PikkuFunc, // Business logic function — omit on a pure command group\n title?: string,\n description?: string,\n render?: PikkuCLIRender, // Custom output renderer\n options?: CLIOptions,\n subcommands?: { [name: string]: PikkuCLICommand }, // nests to any depth\n middleware?: PikkuMiddleware[],\n permissions?: PermissionGroup,\n auth?: boolean,\n isDefault?: boolean, // Runs when the group is invoked with no subcommand\n})\n```\n\n`parameters` is checked against the func's input at compile time — a name that is\nnot a key of the input makes the type `never`, so a typo'd positional fails to\nbuild rather than arriving as `undefined`.\n\n### Options\n\n```typescript\n{\n description: string,\n short?: string, // Single char alias (e.g. 'v')\n default?: any,\n choices?: any[], // Restrict to these values\n array?: boolean, // Collect every value up to the next flag\n required?: boolean,\n}\n```\n\nHow the parser reads them, which is worth knowing before you name one:\n\n- **Flag names are camel-cased**, so `--api-url` and `--apiUrl` both fill `apiUrl`.\n- **`--no-x` negation only works when `x` has a boolean `default`.** Without one,\n `--no-x` parses as an option literally named `noX` — which is why boolean flags\n should always declare their default.\n- Short flags cluster (`-abc`), and only the last in a cluster may take a value.\n- An unknown `--flag` warns rather than throwing.\n\n### `pikkuCLIRender(fn)`\n\n```typescript\nimport { pikkuCLIRender } from '#pikku'\n\nconst renderer = pikkuCLIRender<OutputType>((services, data) => {\n // Format and print output to terminal\n console.log(data)\n})\n```\n\n### Wire object (`wire.cli`)\n\n```typescript\nwire.cli.program // program name\nwire.cli.command // string[] — the resolved command path\nwire.cli.data // all positionals and options, merged\nwire.cli.channel // the channel when served remotely (see below)\n```\n\n## Usage Patterns\n\n### Basic Commands\n\n```typescript\nwireCLI({\n program: 'todos',\n commands: {\n add: pikkuCLICommand({\n parameters: '<text>',\n func: createTodo,\n description: 'Add a new todo',\n render: todoRenderer,\n options: {\n priority: {\n description: 'Set priority',\n short: 'p',\n default: 'normal',\n choices: ['low', 'normal', 'high'],\n },\n },\n }),\n list: pikkuCLICommand({\n func: listTodos,\n description: 'List all todos',\n render: todosRenderer,\n options: {\n completed: {\n description: 'Show completed only',\n short: 'c',\n default: false,\n },\n },\n }),\n },\n})\n// Usage: todos add \"Buy milk\" -p high\n// Usage: todos list -c\n```\n\n### Nested Subcommands\n\n```typescript\nwireCLI({\n program: 'app',\n options: {\n verbose: { description: 'Verbose output', short: 'v', default: false },\n },\n commands: {\n greet: pikkuCLICommand({\n parameters: '<name>',\n func: greetUser,\n render: greetRenderer,\n }),\n\n user: {\n description: 'User management',\n subcommands: {\n create: pikkuCLICommand({\n parameters: '<username> <email>',\n func: createUser,\n render: userRenderer,\n options: {\n admin: { description: 'Admin role', short: 'a', default: false },\n },\n }),\n list: pikkuCLICommand({\n func: listUsers,\n render: usersRenderer,\n options: {\n limit: { description: 'Max results', short: 'l' },\n },\n }),\n },\n },\n },\n})\n// Usage: app greet Alice\n// Usage: app user create bob bob@example.com -a\n// Usage: app user list -l 10\n// Usage: app -v user list\n```\n\n### Custom Renderers\n\nA renderer receives `(services, data)` where `data` is the func's output. Set `render` on `wireCLI` as the program-wide default; set `render` on a `pikkuCLICommand` to override it for that command.\n\n```typescript\nconst todoRenderer = pikkuCLIRender<{ todo: Todo }>((_services, { todo }) => {\n console.log(`✓ Created: ${todo.text} (priority: ${todo.priority})`)\n})\n\nwireCLI({\n program: 'todos',\n render: jsonRenderer, // default for all commands\n commands: {\n add: pikkuCLICommand({ func: createTodo, render: todoRenderer }), // overrides jsonRenderer\n },\n})\n```\n\nThe func's input is the positional `parameters` plus `options`, merged (e.g. `parameters: '<username> <email>'` + an `admin` option → func input `{ username, email, admin }`).\n\nA renderer's full signature is `(services, data, session?)`. It returns nothing —\nprinting is its job.\n\n### Running the program over a websocket\n\nCodegen emits a `<program>-channel.gen.ts` beside your wiring: a `wireChannel`\nthat serves the same commands remotely, so a local binary and a hosted session\nrun identical code. `auth` on `wireCLI` guards **that channel only** — a locally\nexecuted CLI has no connection to authenticate, so it is not a way to require a\nsession for local runs. Don't hand-write or edit the generated channel file.\n\n## Complete Example\n\nFor a full functions + renderers + nested-subcommand wiring walkthrough, see `references/complete-example.md`.\n", "pikku-concepts/references/concept-mapping.md": "# Concept Mapping: Generic Backend → Pikku\n\nAuthoritative mapping table plus side-by-side code examples showing how common backend patterns translate to Pikku.\n\n## Quick Reference Table\n\n| Generic Backend Concept | Pikku Equivalent | Skill |\n| --------------------------------------- | --------------------------------------------------------------- | ----------------- |\n| **Controller / Route Handler** | `pikkuFunc` / `pikkuSessionlessFunc` | `pikku-concepts` |\n| **Route definition** (`GET /users/:id`) | `wireHTTP({ route, method, func })` | `pikku-http` |\n| **Middleware** (Express/Koa-style) | `pikkuMiddleware` | `pikku-security` |\n| **Auth Guard / Auth Middleware** | `authBearer()` / `authCookie()` / `authApiKey()` | `pikku-security` |\n| **Authorization / Permissions** | `pikkuPermission` / `pikkuAuth` | `pikku-security` |\n| **DTO / Request Validation** | Standard Schema (Zod, Valibot, ArkType) | `pikku-concepts` |\n| **Dependency Injection** | `pikkuServices` (singleton) + `pikkuWireServices` (per-request) | `pikku-services` |\n| **WebSocket handlers** | `wireChannel` | `pikku-websocket` |\n| **Job Queue workers** | `wireQueueWorker` | `pikku-queue` |\n| **Cron / Scheduled tasks** | `wireScheduler` | `pikku-cron` |\n| **Module / Feature grouping** | Tags + wiring files | `pikku-concepts` |\n| **Error handling** | Throw typed errors (`NotFoundError`, `ForbiddenError`) | `pikku-concepts` |\n| **Type-safe API client** | `npx pikku all` generates clients | `pikku-concepts` |\n| **Secrets / Config** | `defineSecret`, `defineVariable`, `services.variables` | `pikku-config` |\n\n## Route Handler / Controller → pikkuFunc\n\n**Traditional (generic):**\n\n```typescript\n// A controller method tied to HTTP\nclass TodoController {\n async create(req: Request, res: Response) {\n const { title, priority } = req.body\n const todo = await this.todoService.create(title, priority)\n res.json({ todo })\n }\n}\n// Route: router.post('/todos', controller.create)\n```\n\n**Pikku:**\n\n```typescript\n// Function knows nothing about HTTP.\n// input/output are Zod schemas; the data + return types are inferred from them.\nconst createTodo = pikkuSessionlessFunc({\n input: CreateTodoInput,\n output: TodoOutput,\n func: async ({ todoStore, logger }, { title, priority }) => {\n const todo = todoStore.createTodo(title, priority)\n logger.info(`Created todo: ${todo.id}`)\n return { todo }\n },\n})\n\n// Wiring (separate file) - grouped with defineHTTPRoutes\nexport const todoRoutes = defineHTTPRoutes({\n basePath: '/todos',\n tags: ['todos'],\n auth: false,\n routes: {\n create: { method: 'post', route: '', func: createTodo },\n list: { method: 'get', route: '', func: listTodos },\n },\n})\n\n// Compose into top-level API\nwireHTTPRoutes({\n basePath: '/api',\n routes: { todos: todoRoutes },\n})\n```\n\n**Key difference:** The function receives `{ title, priority }` as typed data - it doesn't know if it came from HTTP body, WebSocket message, or CLI args. Routes are grouped with `defineHTTPRoutes` (like a controller) and composed with `wireHTTPRoutes`.\n\n---\n\n## Route Parameters → Merged into Data\n\n**Traditional:**\n\n```typescript\n// Must extract from req.params, req.query, req.body separately\nasync getUser(req: Request, res: Response) {\n const id = req.params.id // from URL\n const fields = req.query.fields // from query string\n const updates = req.body // from body\n}\n```\n\n**Pikku:**\n\n```typescript\n// All sources merged into a single typed `data` object.\n// input/output are Zod schemas; the data + return types are inferred from them.\nconst getUser = pikkuSessionlessFunc({\n input: z.object({ id: z.string(), fields: z.string().optional() }),\n output: UserOutput,\n func: async (services, { id, fields }) => {\n // id comes from route param, fields from query - function doesn't care\n return { user: await services.db.getUser(id, fields) }\n },\n})\n\nwireHTTP({ method: 'get', route: '/users/:id', func: getUser, auth: false })\n```\n\nPikku merges route params + query string + body + headers into a single typed input.\n\n---\n\n## Middleware → pikkuMiddleware\n\n**Traditional:**\n\n```typescript\n// Express-style middleware\nfunction logRequest(req: Request, res: Response, next: NextFunction) {\n console.log(`${req.method} ${req.url}`)\n next()\n console.log(`Response: ${res.statusCode}`)\n}\napp.use(logRequest)\n```\n\n**Pikku:**\n\n```typescript\nconst logRequest = pikkuMiddleware(async ({ logger }, wire, next) => {\n logger.info('Request started')\n await next()\n logger.info('Request completed')\n})\n\n// Apply globally\naddHTTPMiddleware('*', [logRequest])\n\n// Apply by route pattern\naddHTTPMiddleware('/api/*', [logRequest])\n```\n\n**Key difference:** Pikku middleware receives services (injected), not raw req/res.\n\n---\n\n## Auth Guard → Built-in Auth Middleware\n\n**Traditional:**\n\n```typescript\n// Custom auth middleware\nfunction authMiddleware(req, res, next) {\n const token = req.headers.authorization?.replace('Bearer ', '')\n if (!token) return res.status(401).json({ error: 'Unauthorized' })\n try {\n req.user = jwt.verify(token)\n next()\n } catch {\n res.status(401).json({ error: 'Invalid token' })\n }\n}\n```\n\n**Pikku:**\n\n```typescript\nimport { authBearer } from '@pikku/core/middleware'\n\n// One line - handles token extraction, JWT verification, session population\naddHTTPMiddleware('*', [authBearer({})])\n```\n\nOther built-in options: `authCookie({ cookieName })`, `authApiKey({ header })`.\n\n---\n\n## Authorization / Role Checks → pikkuPermission\n\n**Traditional:**\n\n```typescript\n// Guard or middleware that checks roles\nfunction requireRole(role: string) {\n return (req, res, next) => {\n if (req.user.role !== role)\n return res.status(403).json({ error: 'Forbidden' })\n next()\n }\n}\nrouter.delete('/users/:id', requireRole('admin'), deleteUser)\n```\n\n**Pikku:**\n\n```typescript\nconst isOwner = pikkuPermission(async ({ db }, { userId }, wire) => {\n const { session } = wire\n return session?.userId === userId\n})\n\n// Declare on the function definition (the only place permissions live).\n// A capability like \"may delete users\" is a scope, not a permission.\nexport const deleteUser = pikkuFunc({\n func: async (services, data) => {\n /* ... */\n },\n scopes: ['admin:users:delete'],\n permissions: { owner: [isOwner] },\n})\n\n// App-wide baseline every function must also pass (AND gate, narrow-only)\naddGlobalPermission([signedInUser])\n```\n\n**Permission groups:** `{ groupA: [perm1, perm2], groupB: [perm3] }` means `(perm1 AND perm2) OR perm3`.\n\n> Route-pattern (`addHTTPPermission`) and wire-level `permissions` were removed in #972 — permissions live on the function, plus the optional global gate.\n\n---\n\n## DTO / Request Validation → Standard Schema\n\n**Traditional (class-validator):**\n\n```typescript\nclass CreateTodoDTO {\n @IsString()\n @IsNotEmpty()\n @MaxLength(200)\n title: string\n\n @IsOptional()\n @IsIn(['low', 'medium', 'high'])\n priority?: string\n}\n```\n\n**Pikku (Zod):**\n\n```typescript\nconst CreateTodoInputSchema = z.object({\n title: z.string().min(1).max(200),\n priority: z.enum(['low', 'medium', 'high']).optional(),\n})\n\n// Schema declared on function - auto-validated before function runs\nconst createTodo = pikkuSessionlessFunc({\n input: CreateTodoInputSchema,\n output: TodoOutputSchema,\n func: async (services, data) => { ... },\n})\n```\n\nAlso supports Valibot, ArkType, or any Standard Schema-compatible library.\n\n---\n\n## Dependency Injection → Service Factories\n\n**Traditional (class-based DI):**\n\n```typescript\n@Injectable()\nclass TodoService {\n constructor(\n @Inject('DATABASE') private db: Database,\n private logger: Logger\n ) {}\n\n async create(title: string) {\n this.logger.info('Creating todo')\n return this.db.insert('todos', { title })\n }\n}\n```\n\n**Pikku:**\n\n```typescript\n// Define services once at startup\nconst createSingletonServices = pikkuServices(async (config) => ({\n logger: new ConsoleLogger(),\n db: new KyselyService(config.database),\n todoStore: new TodoStore(),\n}))\n\n// Functions destructure what they need\nconst createTodo = pikkuSessionlessFunc(\n async ({ logger, todoStore }, { title }) => {\n logger.info('Creating todo')\n return { todo: todoStore.createTodo(title) }\n }\n)\n```\n\n**Key difference:** No container, no decorators, no class hierarchy. Just a factory function returning an object.\n\nPer-request services (like scoped loggers) use `pikkuWireServices`:\n\n```typescript\nconst createWireServices = pikkuWireServices(\n async (singletonServices, wire) => ({\n scopedLogger: new ScopedLogger(wire.session?.initial?.userId),\n })\n)\n```\n\n---\n\n## WebSocket Handlers → wireChannel\n\n**Traditional:**\n\n```typescript\nwss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const { type, payload } = JSON.parse(raw)\n switch (type) {\n case 'subscribe':\n handleSubscribe(ws, payload)\n break\n case 'create':\n handleCreate(ws, payload)\n break\n }\n })\n ws.on('close', () => handleDisconnect(ws))\n})\n```\n\n**Pikku:**\n\n```typescript\nwireChannel({\n name: 'todos-live',\n route: '/',\n onConnect, // pikkuVoidFunc\n onDisconnect, // pikkuVoidFunc\n onMessageWiring: {\n action: {\n // First level of message routing\n subscribe: { func: subscribe },\n create: { func: createTodo },\n auth: { func: login, auth: false },\n },\n },\n})\n```\n\nFunctions send data back via `wire.channel.send(data)`. Structured message routing replaces manual switch/case parsing.\n\n---\n\n## Job Queue Workers → wireQueueWorker\n\n**Traditional (Bull/BullMQ):**\n\n```typescript\nconst queue = new Queue('reminders')\n\n// Producer\nawait queue.add('send-reminder', { todoId: '123', userId: 'user1' })\n\n// Consumer\nconst worker = new Worker('reminders', async (job) => {\n const { todoId, userId } = job.data\n await sendReminder(todoId, userId)\n})\n```\n\n**Pikku:**\n\n```typescript\n// Same pikkuFunc shape - knows nothing about queues\nconst processReminder = pikkuSessionlessFunc(\n async ({ todoStore, logger }, { todoId, userId }) => {\n const todo = todoStore.getTodo(todoId)\n if (todo && !todo.completed) {\n logger.info(`Sending reminder for: ${todo.title}`)\n }\n return { processed: true }\n }\n)\n\n// Wire it to a queue\nwireQueueWorker({ name: 'todo-reminders', func: processReminder })\n\n// Enqueue from other functions via queue service\nawait services.queueService.addJob('todo-reminders', {\n todoId: '123',\n userId: 'user1',\n})\n```\n\n---\n\n## Cron / Scheduled Tasks → wireScheduler\n\n**Traditional:**\n\n```typescript\nimport cron from 'node-cron'\ncron.schedule('0 9 * * *', async () => {\n const stats = await todoService.getStats()\n console.log(`Daily: ${stats.completed}/${stats.total}`)\n})\n```\n\n**Pikku:**\n\n```typescript\nconst dailySummary = pikkuVoidFunc(async ({ logger, todoStore }) => {\n const stats = todoStore.getStats('user1')\n logger.info(`Daily: ${stats.completed}/${stats.total}`)\n})\n\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n})\n```\n\n---\n\n## Module / Feature Grouping → Tags + File Organization\n\n**Traditional (NestJS):**\n\n```typescript\n@Module({\n imports: [DatabaseModule],\n controllers: [TodoController],\n providers: [TodoService],\n exports: [TodoService],\n})\nexport class TodoModule {}\n```\n\n**Pikku:**\n\n```text\n// No module system. Organize by convention:\nsrc/\n├── functions/\n│ └── todos.functions.ts # All todo business logic\n├── wirings/\n│ └── todos.http.ts # All todo HTTP routes\n└── schemas.ts # Shared schemas\n\n// Use tags for cross-cutting concerns:\nwireHTTP({ route: '/todos', func: listTodos, tags: ['todos', 'public'] })\naddMiddleware('todos', [loggingMiddleware]) // Applies to all 'todos'-tagged functions\n```\n\n---\n\n## Error Handling → Typed Errors\n\n**Traditional:**\n\n```typescript\nif (!todo) {\n res.status(404).json({ error: 'Todo not found' })\n return\n}\nif (!canEdit(user, todo)) {\n res.status(403).json({ error: 'Forbidden' })\n return\n}\n```\n\n**Pikku:**\n\n```typescript\nimport { NotFoundError, ForbiddenError } from '@pikku/core/errors'\n\nconst updateTodo = pikkuFunc(async (services, { id, title }, wire) => {\n const todo = services.todoStore.getTodo(id)\n if (!todo) throw new NotFoundError('Todo not found')\n\n const { session } = wire\n if (todo.userId !== session.userId) throw new ForbiddenError()\n\n return { todo: services.todoStore.update(id, { title }) }\n})\n```\n\nPikku catches these errors and maps them to appropriate HTTP status codes automatically.\n\n---\n\n## Session Management\n\n**Traditional:**\n\n```typescript\n// Express session\napp.use(session({ store: new RedisStore({ client: redis }), secret: 'key' }))\n\n// In handler\nreq.session.userId = user.id // Set\nconst userId = req.session.userId // Get\nreq.session.destroy() // Clear\n```\n\n**Pikku:**\n\n```typescript\n// Session is on the wire context, managed by auth middleware\nconst login = pikkuSessionlessFunc(\n async ({ jwt }, { username, password }, wire) => {\n const user = authenticate(username, password)\n const token = await jwt.sign({ userId: user.id })\n await wire.setSession({ userId: user.id, user }) // Set\n return { token, user }\n }\n)\n\nconst getMe = pikkuFunc(async (services, data, wire) => {\n const { session } = wire // Get\n return { user: session.user }\n})\n\nconst logout = pikkuFunc(async (services, data, wire) => {\n await wire.clearSession() // Clear\n return { success: true }\n})\n```\n\n---\n\n## API Client Generation\n\n**Traditional:**\n\n```typescript\n// Manually written or generated via OpenAPI\nconst response = await fetch('/api/todos', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: 'New todo' }),\n})\nconst data: unknown = await response.json()\n```\n\n**Pikku (auto-generated, fully typed):**\n\n```typescript\nimport { createPikkuFetchClient } from './.pikku/pikku-fetch.gen.js'\n\nconst client = createPikkuFetchClient({ baseUrl: 'http://localhost:4002' })\nconst result = await client.post('/todos', { title: 'New todo' })\n// result is fully typed as TodoOutput - no manual type casting\n```\n\nGenerated by running `npx pikku all`. Both HTTP and WebSocket clients available.\n", "pikku-concepts/references/packages.md": "# Available Pikku Packages\n\n## Runtime Adapters\n\n| Package | Use Case |\n| ----------------------------- | ------------------------------------- |\n| `@pikku/express-server` | Express standalone server |\n| `@pikku/express-middleware` | Express as middleware in existing app |\n| `@pikku/fastify-server` | Fastify standalone |\n| `@pikku/fastify-plugin` | Fastify plugin |\n| `@pikku/next` | Next.js API routes |\n| `@pikku/aws-lambda` | AWS Lambda handlers |\n| `@pikku/cloudflare` | Cloudflare Workers |\n| `@pikku/uws-server` | uWebSockets.js (high perf) |\n| `@pikku/modelcontextprotocol` | MCP server |\n\n## Service Packages\n\n| Package | Provides |\n| ------------------------ | ---------------------------------------------------- |\n| `@pikku/jose` | JWT (sign/verify) via jose library |\n| `@pikku/schema-ajv` | Schema validation via AJV |\n| `@pikku/schema-cfworker` | Schema validation for Cloudflare |\n| `@pikku/pino` | Structured logging via Pino |\n| `@pikku/kysely` | Type-safe SQL via Kysely (PostgreSQL, SQLite, MySQL) |\n| `@pikku/redis` | Redis client |\n| `@pikku/queue-bullmq` | Job queues via BullMQ |\n| `@pikku/queue-pg-boss` | Job queues via PgBoss |\n| `@pikku/aws-services` | AWS SDK (SQS, DynamoDB, etc.) |\n", "pikku-concepts/SKILL.md": "---\nname: pikku-concepts\ndescription: >-\n Foundational guide to Pikku framework concepts. Use this skill when working with any Pikku\n codebase, starting a new Pikku project, or migrating a backend to Pikku. Covers the core mental\n model, function types, project structure, code generation, testing, and how Pikku maps to\n traditional backend patterns. TRIGGER when: user asks \"what is Pikku?\", starts a new Pikku\n project, migrates from Express/NestJS/Hono, or needs to understand how Pikku works. DO NOT\n TRIGGER when: user is doing a specific wiring task (use the specific skill instead, e.g.\n pikku-http, pikku-websocket).\ninstallGroups: [core]\n---\n\n# Pikku Framework Concepts\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nPikku is a TypeScript framework that separates business logic from transport mechanisms. You define a function once, then wire it to HTTP, WebSocket, queues, schedulers, MCP, CLI, or RPC — without the function knowing how it's being called.\n\nFor deep-dive on each topic, see the dedicated skills:\n\n- **Wiring**: `pikku-http`, `pikku-websocket`, `pikku-rpc`, `pikku-mcp`, `pikku-queue`, `pikku-cron`, `pikku-trigger`, `pikku-cli`, `pikku-ai-agent`, `pikku-workflow`\n- **Authorization**: `pikku-security` (authentication/sessions), `pikku-permissions` (permission checks, scopes), `pikku-middleware` (global/tag/route middleware)\n- **Infrastructure**: `pikku-services`, `pikku-config`\n- **Project introspection**: `pikku-info`\n\n## Core Mental Model\n\n```text\npikkuFunc (pure business logic)\n │\n ├── wireHTTP → Express, Fastify, Next.js, Lambda, Cloudflare...\n ├── wireChannel → WebSocket (real-time)\n ├── wireQueueWorker → BullMQ, PgBoss (async jobs)\n ├── wireScheduler → Cron (scheduled tasks)\n ├── wireMCPTool → Model Context Protocol (AI tools)\n ├── wireCLI → CLI commands\n ├── wireTrigger → Event-driven (Redis pub/sub, PG LISTEN/NOTIFY)\n ├── pikkuAIAgent → AI agents / chatbots\n ├── pikkuWorkflow → Multi-step durable workflows\n └── wire.rpc → Internal function-to-function calls\n```\n\nA `pikkuFunc` receives three things:\n\n1. **Services** — injected dependencies (logger, db, jwt, custom stores). See `pikku-services`.\n2. **Data** — input from any source (HTTP body/query/params, WS message, queue payload, CLI args)\n3. **Wire** — transport context (session, channel, rpc, mcp, http, queue)\n\nThe function never imports Express, never reads `req.body`, never touches `ws.send()`. It just works with typed data and services.\n\n## Concept Mapping: Generic Backend → Pikku\n\nControllers/routes → `pikkuFunc`; auth/sessions → `pikku-security`; authorization checks → `pikku-permissions`; request interception → `pikku-middleware`; DI → `pikku-services`; transports (HTTP/WS/queue/cron) → their `wire*` + skill. For the full Generic Backend → Pikku mapping table (with side-by-side code examples), read `references/concept-mapping.md`.\n\n## Functions\n\nThree main function types:\n\n```typescript\n// Requires authentication — receives session in wire context.\n// input/output are Zod schemas; the data + return types are inferred from them.\nconst updateTodo = pikkuFunc({\n input: UpdateTodoInput,\n output: TodoOutput,\n func: async (services, data, wire) => {\n const { session } = wire\n return services.todoStore.update(data.id, data)\n },\n})\n\n// No authentication required\nconst listTodos = pikkuSessionlessFunc({\n input: ListTodosInput,\n output: TodoListOutput,\n func: async (services, data) => {\n return { todos: services.todoStore.list(data.filters) }\n },\n})\n\n// No input or output (for scheduled tasks, lifecycle hooks)\nconst cleanup = pikkuVoidFunc(async (services) => {\n services.todoStore.cleanOldItems()\n})\n```\n\nServices can be destructured inline in the `func` signature (e.g. `async ({ logger, todoStore }, { title }) => ...`). Full config options:\n\n```typescript\npikkuFunc({\n // Identity and documentation\n title?: string, // Human-readable name\n description?: string, // What the function does\n version?: number, // Contract version (see pikku-versioning)\n override?: string, // Logical name override, so several exports share a versioned base\n tags?: string[], // For grouping and middleware targeting\n\n // Contract\n input?: ZodSchema, // Input validation schema\n output?: ZodSchema, // Output validation schema\n errors?: Array<typeof PikkuError>, // Errors this function may throw\n\n // Reachability\n expose?: boolean, // Allow external RPC calls (see pikku-rpc)\n remote?: boolean, // Allow remote RPC calls\n mcp?: boolean, // Expose as MCP tool (see pikku-mcp)\n readonly?: boolean, // Declares the function performs no writes\n deploy?: 'serverless' | 'server' | 'auto',\n\n // Authorization — see pikku-permissions\n auth?: boolean, // Override default auth requirement\n scopes?: ScopeId[], // AND-ed, checked before permissions; session required\n permissions?: PermissionGroup, // OR-ed pool\n permissionsInBody?: boolean, // Last resort; needs allow.permissionsInBody in config\n middleware?: PikkuMiddleware[], // See pikku-middleware\n\n // Agent tooling — see pikku-ai-agent\n approvalRequired?: boolean,\n approvalDescription?: (services, data) => Promise<string>,\n\n // Workflow step behavior — see pikku-workflow\n workflowQueued?: boolean, // Dispatch via queue instead of inline\n workflowRetries?: number,\n workflowTimeout?: string, // e.g. '30s', '5m'\n\n audit?: boolean | { durability?: 'best-effort' | 'transactional' },\n\n func: async (services, data, wire) => { ... },\n})\n```\n\n`scopes` is the one option `pikkuSessionlessFunc` does not accept, and the\nomission is deliberate: scopes are AND-ed and fail closed, so an anonymous\ncaller holds none and satisfies none — a sessionless function with scopes would\nreject every caller it exists to serve. Gate those with `permissions`, which\nreceive the optional session and may pass anonymous.\n\n**Generics XOR `input`/`output` — never both.** A function's data and return\ntypes come from *one* source: either the `input`/`output` schemas (preferred —\nthey double as runtime validation and OpenAPI) or type generics\n(`pikkuFunc<In, Out>({ ... })`). Passing both makes the two disagree and forces\n`as any` casts. Do not annotate the `func` return type inline either — let the\n`output` schema (or the generic) be the single source of truth for the type.\n\n```typescript\n// Correct — schema-based (no generics, no inline return type)\npikkuFunc({ input: MyInput, output: MyOutput, func: async (s, d) => { ... } })\n// Correct — generic-based (no input/output)\npikkuFunc<MyIn, MyOut>({ func: async (s, d) => { ... } })\n// WRONG — mixing the two\npikkuFunc<MyIn, MyOut>({ input: MyInput as any, func: async (s, d) => { ... } })\n```\n\n## Schemas (Validation)\n\nPikku uses Standard Schema — works with Zod, Valibot, ArkType:\n\n```typescript\nimport { z } from 'zod'\n\nconst CreateTodoInputSchema = z.object({\n title: z.string().min(1).max(200),\n priority: z.enum(['low', 'medium', 'high']).optional(),\n tags: z.array(z.string()).optional(),\n})\n```\n\nSchemas serve triple duty: runtime validation, TypeScript types, and OpenAPI documentation.\n\n## Server Bootstrap\n\nThere are two ways to start a Pikku app. Pick based on whether you need to own the HTTP server.\n\n**1. Let Pikku own the server (preferred when you don't need a specific runtime)**\n\n`pikku dev` and `pikku serve` create the config and singleton services, start the server, and shut it down cleanly. You write no bootstrap code at all — startup and shutdown work goes in lifecycle hooks:\n\n```typescript\n// src/lifecycle.ts\nimport { pikkuServerLifecycle } from '@pikku/core'\nimport type { SingletonServices } from '../types/application-types.js'\n\nexport const lifecycle = pikkuServerLifecycle<SingletonServices>({\n beforeStart: async ({ kysely }) => {\n await runMigrations(kysely)\n },\n afterStart: async ({ logger }) => {\n logger.info('accepting traffic')\n },\n beforeStop: async ({ queueService }) => {\n await queueService.drain()\n },\n})\n```\n\nExport exactly one `pikkuServerLifecycle` from anywhere in `srcDirectories` — the inspector finds it by the wrapper call. Every hook is optional and receives the already-created singleton services. See pikku-services for the ordering and the `afterStop` caveat.\n\n**2. Bootstrap it yourself (required for a specific runtime)**\n\nExpress, Fastify, uWS, Lambda, Cloudflare and Next.js need their own entrypoint, because Pikku is embedded in a server you own:\n\n```typescript\nimport '../../functions/.pikku/pikku-bootstrap.gen.js' // Generated — registers all wirings\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\n// Pick your runtime:\nconst server = new PikkuFastifyServer(\n config,\n singletonServices,\n createWireServices\n)\n// or: new PikkuExpressServer(config, singletonServices, createWireServices)\n// or: pikkuAWSLambdaHandler(singletonServices)\n// or: PikkuCloudflareHandler(singletonServices)\n// or: pikkuNextHandler(singletonServices)\n\nawait server.init()\nawait server.start()\n```\n\n**Lifecycle hooks do not run on this path** — only `pikku dev` and `pikku serve` invoke them. Do your startup work directly in the entrypoint instead.\n\n`pikku validate` warns when a project starts a server by hand *and* depends on no runtime adapter, since that combination means path 1 was available and unused. Silence it with `\"lint\": { \"customServerBootstrap\": \"off\" }` in `pikku.config.json`.\n\n## Code Generation\n\nRun `npx pikku all` to generate:\n\n- `pikku-types.gen.ts` — Typed function factories and wiring functions\n- `pikku-fetch.gen.ts` — Type-safe HTTP client\n- `pikku-websocket.gen.ts` — Type-safe WebSocket client\n- `pikku-bootstrap.gen.ts` — Runtime initialization (auto-imports all wirings)\n- `pikku-services.gen.ts` — Service factory types\n\nConfig lives in `pikku.config.json`:\n\n```json\n{\n \"tsconfig\": \"./tsconfig.json\",\n \"srcDirectories\": [\"src\"],\n \"outDir\": \".pikku\"\n}\n```\n\n## Project Structure Convention\n\n```text\nsrc/\n├── functions/ # Business logic (pikkuFunc definitions)\n│ ├── todos.functions.ts\n│ ├── auth.functions.ts\n│ └── scheduled.functions.ts\n├── wirings/ # Transport bindings\n│ ├── todos.http.ts\n│ ├── channel.wiring.ts\n│ ├── scheduler.wiring.ts\n│ └── queue.wiring.ts\n├── schemas.ts # Zod/Valibot schemas\n├── services.ts # Service factories (see pikku-services)\n├── lifecycle.ts # Server lifecycle hooks (pikku dev/serve only)\n├── middleware.ts # Middleware definitions (see pikku-security)\n├── permissions.ts # Permission definitions (see pikku-security)\n└── .pikku/ # Generated (gitignored)\n ├── pikku-types.gen.ts\n ├── pikku-fetch.gen.ts\n └── pikku-bootstrap.gen.ts\n```\n\n## Environment Variables\n\nNever use `process.env` inside Pikku functions. Use the `variables` service (see `pikku-config`):\n\n```typescript\nconst apiKey = services.variables.get('API_KEY')\n```\n\n`process.env` belongs in server bootstrap code (`start.ts`) only.\n\n## Secrets\n\n`secrets` is not part of a function's services. It is available only in\n`pikkuServices`, `pikkuWireServices`, addon service factories and middleware —\nread it there, give the value to a service, and have the function ask that\nservice. Reaching for it through a cast throws at runtime.\n\n## Testing\n\nFunctions are easily testable because they're pure:\n\n```typescript\nconst mockServices = {\n logger: new MockLogger(),\n todoStore: new MockTodoStore(),\n}\n\n// Call function directly — no HTTP, no framework\nconst result = await listTodos.func(mockServices, { userId: 'test' })\nexpect(result.todos).toHaveLength(3)\n```\n\n## Available Packages\n\nPikku ships runtime adapters (`@pikku/express-server`, `@pikku/fastify-server`, `@pikku/next`, `@pikku/aws-lambda`, `@pikku/cloudflare`, `@pikku/uws-server`, `@pikku/modelcontextprotocol`, ...) and service packages (`@pikku/jose`, `@pikku/schema-ajv`, `@pikku/pino`, `@pikku/kysely`, `@pikku/redis`, `@pikku/queue-bullmq`, `@pikku/queue-pg-boss`, ...). For the full list with use cases, read `references/packages.md`.\n\n## Key Differences from Traditional Frameworks\n\n1. **No decorators** — plain functions + explicit wiring, not `@Get()` or `@Injectable()`\n2. **No classes required** — everything is functions and objects\n3. **Transport is configuration, not code** — business logic doesn't know about HTTP/WS/etc.\n4. **One function, many transports** — same function can serve HTTP, WebSocket, queue, and MCP simultaneously\n5. **Generated type safety** — clients are auto-generated with full types, not manually maintained\n6. **Schema-first validation** — Standard Schema (Zod/Valibot) replaces class-validator decorators\n", "pikku-config/SKILL.md": "---\nname: pikku-config\ndescription: >-\n Use when managing secrets, environment variables, config, or OAuth2 credentials in a Pikku app.\n Covers defineSecret, defineVariable, defineCredential, and typed config access. TRIGGER when:\n code uses defineSecret/defineVariable/defineCredential, user asks about env vars, secrets,\n config, OAuth2, SecretValue/.reveal(), SecretCoercionError, or \"how do I access environment\n variables\". DO NOT TRIGGER when: user asks about API versioning/breaking changes (use\n pikku-versioning), service factories (use pikku-services), middleware (use pikku-middleware), or\n auth strategies and sessions (use pikku-security).\ninstallGroups: [core]\n---\n\n# Pikku Config, Secrets & OAuth2\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nManage secrets, variables, and OAuth2 credentials. Never use `process.env` in Pikku functions — use typed services instead.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their versions\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## Secrets & Variables\n\n### `defineSecret(config)`\n\nDeclare a secret with a Zod schema for type-safe access:\n\n```typescript\ndefineSecret({\n name: string, // Secret identifier\n schema: ZodSchema, // Shape and validation\n})\n```\n\n### `defineVariable(config)`\n\nDeclare a variable (non-sensitive config) with a Zod schema:\n\n```typescript\ndefineVariable({\n name: string,\n schema: ZodSchema,\n})\n```\n\n### Accessing Secrets\n\n`secrets` is **not available inside functions, AI agents, workflows, permissions\nor any wire** — it is removed from their services type and throws at runtime if\nreached through a cast. Read it where you wire the app and hand the value to a\nservice:\n\n`getSecret` returns a `SecretValue<T>`, not the bare value. It is nominal — not\nassignable to `string`, so every concretely-typed sink rejects it — it serializes\nto `[secret]` in logs and audits, and coercing it to a string (a template\nliteral, a concatenation) throws `SecretCoercionError`, because that is always a\nleak. `.reveal()` is the one way out, which makes every disclosure deliberate and\ngreppable. Call it at the point the value reaches the thing that needs it:\n\n```typescript\n// services.ts — allowed\nconst createSingletonServices = pikkuServices(async (config, { secrets }) => ({\n stripe: new StripeService((await secrets.getSecret('STRIPE_CONFIG')).reveal()),\n}))\n\n// functions/*.ts — ask the service, never the secret store\nexport const charge = pikkuFunc({\n func: async ({ stripe }, data) => stripe.charge(data.amount),\n})\n```\n\nAllowed: `pikkuServices`, `pikkuWireServices`, addon service factories,\nmiddleware. Everywhere else, the service you constructed is the interface.\n\n### Accessing Variables in Functions\n\n```typescript\n// Variables — plain-text configuration\nconst flags = await services.variables.getVariableJSON('VARIABLE_NAME')\n\n// Simple string access\nconst apiKey = services.variables.get('API_KEY')\n```\n\n### Local Development Services\n\n```typescript\nimport { LocalSecretService, LocalVariablesService } from '@pikku/core/services'\n\nconst createSingletonServices = pikkuServices(async (config) => ({\n secrets: new LocalSecretService(), // Reads from .env or local files\n variables: new LocalVariablesService(), // Reads from environment\n}))\n```\n\n### Usage Patterns\n\n```typescript\n// Declare secrets with typed schemas\ndefineSecret({\n name: 'STRIPE_CONFIG',\n schema: z.object({\n apiKey: z.string().startsWith('sk_'),\n webhookSecret: z.string(),\n }),\n})\n\n// In your services factory — fully typed\nconst config = (await secrets.getSecret('STRIPE_CONFIG')).reveal()\n// config.apiKey → string (autocompleted)\n// config.webhookSecret → string (autocompleted)\n\n// Declare variables\ndefineVariable({\n name: 'FEATURE_FLAGS',\n schema: z.object({\n darkMode: z.boolean(),\n maxUploadMB: z.number().default(10),\n }),\n})\n\n// Read it — typed and validated\nconst flags = await variables.getVariableJSON('FEATURE_FLAGS')\n// flags.darkMode → boolean\n// flags.maxUploadMB → number\n```\n\n## Credentials\n\n### `defineCredential(config)`\n\n```typescript\ndefineCredential({\n name: string, // Credential identifier\n displayName: string, // Human-readable name\n type: 'wire' | 'singleton', // Per-user ('wire') or platform-level ('singleton')\n schema: ZodSchema, // Shape of the stored credential\n oauth2?: { // Omit entirely for a plain API key\n appCredentialSecretId: string, // Secret holding { clientId, clientSecret }\n tokenSecretId: string, // Secret for token storage (auto-refreshed)\n authorizationUrl: string, // OAuth2 authorization endpoint\n tokenUrl: string, // OAuth2 token endpoint\n scopes: string[], // Required OAuth2 scopes\n },\n})\n```\n\n### Usage\n\n```typescript\n// Per-user API key — no oauth2 block\ndefineCredential({\n name: 'stripe',\n displayName: 'Stripe API Key',\n type: 'wire',\n schema: z.object({ apiKey: z.string() }),\n})\n\n// Platform-level OAuth (singleton)\ndefineCredential({\n name: 'slack',\n displayName: 'Slack',\n type: 'singleton',\n schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),\n oauth2: {\n appCredentialSecretId: 'SLACK_OAUTH_APP',\n tokenSecretId: 'SLACK_OAUTH_TOKENS',\n authorizationUrl: 'https://slack.com/oauth/v2/authorize',\n tokenUrl: 'https://slack.com/api/oauth.v2.access',\n scopes: ['chat:write', 'channels:read'],\n },\n})\n\n### Reading a Credential\n\nA declared credential is resolved per invocation through `wire.getCredential(name)`,\nso the natural place to read it is a wire service factory: build the client there\nonce and let functions ask the client, the same way they ask a service for a\nsecret-derived value. Tokens refresh automatically, so what arrives is already\nvalid.\n\n```typescript\nexport const createWireServices = pikkuWireServices(async (_services, wire) => {\n const cred = await wire.getCredential?.<{ accessToken: string }>('slack')\n if (!cred?.accessToken) {\n // Tells the caller which credential to connect, and where.\n throw new MissingCredentialError('slack', 'oauth2', '/credentials/slack/connect')\n }\n return { slack: new SlackClient(cred.accessToken) }\n})\n\n// functions/*.ts — ask the client, never the credential store\nexport const postMessage = pikkuFunc({\n func: async ({ slack }, { channel, text }) => slack.postMessage(channel, text),\n})\n```\n\nA `wire` credential resolves per user, so an unconnected user hits\n`MissingCredentialError` rather than silently acting as someone else; a\n`singleton` credential is platform-level and identical for every caller.\n\n## Key Rule\n\n**Never use `process.env` inside Pikku functions.** Use the `variables` or `secrets` service:\n\n```typescript\n// ❌ Wrong\nconst apiKey = process.env.API_KEY\n\n// ✅ Correct\nconst apiKey = services.variables.get('API_KEY')\n```\n\n`process.env` belongs only in server bootstrap code (`start.ts`). Under `pikku dev` / `pikku serve` there is no `start.ts` — startup work goes in a `pikkuServerLifecycle` export, and the hooks receive the singleton services, so read configuration through `variables` there too (see pikku-services).\n\n### Lint rules\n\n`pikku.config.json` can set the severity of individual checks:\n\n```json\n{\n \"lint\": {\n \"servicesNotDestructured\": \"error\",\n \"wiresNotDestructured\": \"error\",\n \"functionDynamicImport\": \"warn\",\n \"customServerBootstrap\": \"warn\"\n }\n}\n```\n\n`customServerBootstrap` is the one evaluated by `pikku validate` rather than codegen: it warns when the root `start`/`dev` script boots a server without `pikku dev` / `pikku serve` and no runtime adapter is installed. Set it to `\"off\"` to keep a hand-rolled entrypoint, or `\"error\"` to enforce the hooks.\n\n## Complete Example\n\n```typescript\n// schemas/config.ts\ndefineSecret({\n name: 'DATABASE_CONFIG',\n schema: z.object({\n connectionString: z.string().url(),\n maxPoolSize: z.number().default(10),\n }),\n})\n\ndefineVariable({\n name: 'APP_CONFIG',\n schema: z.object({\n appName: z.string(),\n maxUploadSizeMB: z.number().default(10),\n maintenanceMode: z.boolean().default(false),\n }),\n})\n\ndefineCredential({\n name: 'githubOAuth',\n displayName: 'GitHub OAuth',\n type: 'wire',\n schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),\n oauth2: {\n appCredentialSecretId: 'GITHUB_OAUTH_APP',\n tokenSecretId: 'GITHUB_OAUTH_TOKENS',\n authorizationUrl: 'https://github.com/login/oauth/authorize',\n tokenUrl: 'https://github.com/login/oauth/access_token',\n scopes: ['read:user', 'repo'],\n },\n})\n\n// functions/admin.functions.ts\nexport const getAppStatus = pikkuSessionlessFunc({\n title: 'Get App Status',\n func: async ({ variables }) => {\n const appConfig = await variables.getVariableJSON('APP_CONFIG')\n return {\n appName: appConfig.appName,\n maintenanceMode: appConfig.maintenanceMode,\n }\n },\n})\n```\n", "pikku-cron/SKILL.md": "---\nname: pikku-cron\ndescription: >-\n Use when adding scheduled tasks, recurring jobs, or cron-based automation to a Pikku app. Covers\n wireScheduler, cron expressions, scheduled task wire object, and scheduler middleware. TRIGGER\n when: code uses wireScheduler, user asks about cron, scheduled tasks, recurring jobs, or \"run\n every X minutes/hours\". DO NOT TRIGGER when: user asks about background jobs with retries (use\n pikku-queue) or event-driven triggers (use pikku-trigger).\ninstallGroups: [core]\n---\n\n# Pikku Cron/Scheduler Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to run on a schedule using cron expressions. Uses `pikkuVoidFunc` (no input/output).\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireScheduler(config)`\n\n```typescript\nimport { wireScheduler } from '@pikku/core/scheduler'\n\nwireScheduler({\n name: string, // Unique scheduler name\n schedule: string, // Cron expression\n func: PikkuVoidFunc, // Must be pikkuVoidFunc (no input/output)\n tags?: string[], // Targets tag middleware — see pikku-middleware\n middleware?: PikkuMiddleware[],\n})\n```\n\n### Wire Object (`wire.scheduledTask`)\n\nInside scheduled functions:\n\n```typescript\nwire.scheduledTask.name // Scheduler name\nwire.scheduledTask.schedule // Cron expression string\nwire.scheduledTask.executionTime // Date this execution was triggered\nwire.scheduledTask.skip(reason?) // Abort this execution — THROWS, never returns\n```\n\n**`skip()` aborts by throwing.** It reads like an early return but it is not:\nnothing after the call runs, so there is no need to `return` afterwards. The\nconsequence that bites is in middleware — a `try/catch` around `await next()`\nwill catch a skip and report it as a failure. If your middleware distinguishes\nsuccess from failure, let the skip pass through rather than logging it as an\nerror.\n\n### Cron Expression Reference\n\n```\n┌───────────── minute (0-59)\n│ ┌───────────── hour (0-23)\n│ │ ┌───────────── day of month (1-31)\n│ │ │ ┌───────────── month (1-12)\n│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 = Sunday)\n│ │ │ │ │\n* * * * *\n```\n\nCommon patterns:\n\n| Expression | Meaning |\n| ------------- | -------------------------- |\n| `*/5 * * * *` | Every 5 minutes |\n| `0 9 * * *` | Daily at 9:00 AM |\n| `0 9 * * 1` | Every Monday at 9:00 AM |\n| `0 0 1 * *` | First of month at midnight |\n| `0 */6 * * *` | Every 6 hours |\n| `30 2 * * 0` | Sundays at 2:30 AM |\n\n## Usage Patterns\n\n### Basic Scheduled Task\n\n```typescript\nconst dailySummary = pikkuVoidFunc({\n title: 'Daily Summary',\n func: async ({ db, emailService, logger }) => {\n logger.info('Generating daily summary')\n const stats = await db.getDailyStats()\n await emailService.sendSummary(stats)\n },\n})\n\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n})\n```\n\n### Using the Wire Object\n\n```typescript\nconst weeklyCleanup = pikkuVoidFunc({\n title: 'Weekly Cleanup',\n func: async ({ db, logger }, _input, wire) => {\n logger.info(`Running: ${wire.scheduledTask.name}`)\n logger.info(`Schedule: ${wire.scheduledTask.schedule}`)\n logger.info(`Execution time: ${wire.scheduledTask.executionTime}`)\n\n const staleCount = await db.countStaleTodos()\n if (staleCount === 0) {\n wire.scheduledTask.skip('No stale todos found') // throws — nothing below runs\n }\n\n await db.deleteCompletedTodos({ olderThan: '30d' })\n logger.info(`Cleaned ${staleCount} stale todos`)\n },\n})\n\nwireScheduler({\n name: 'weeklyCleanup',\n schedule: '0 0 * * 0',\n func: weeklyCleanup,\n})\n```\n\n### Scheduler Middleware\n\n```typescript\nconst schedulerMetrics = pikkuMiddleware(\n async ({ logger }, { scheduledTask }, next) => {\n const start = Date.now()\n logger.info(`Task started: ${scheduledTask.name}`)\n\n try {\n await next()\n logger.info(`Task completed: ${scheduledTask.name}`, {\n duration: Date.now() - start,\n })\n } catch (error) {\n logger.error(`Task failed: ${scheduledTask.name}`, {\n error: error.message,\n duration: Date.now() - start,\n })\n throw error\n }\n }\n)\n\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n middleware: [schedulerMetrics],\n})\n```\n\n## Complete Example\n\n```typescript\n// functions/scheduled.functions.ts\nexport const dailySummary = pikkuVoidFunc({\n title: 'Daily Summary',\n func: async ({ db, emailService, logger }) => {\n const stats = await db.getDailyStats()\n await emailService.sendSummary(stats)\n logger.info('Daily summary sent', { stats })\n },\n})\n\nexport const cleanupExpired = pikkuVoidFunc({\n title: 'Cleanup Expired',\n func: async ({ db, logger }, _input, wire) => {\n const count = await db.countExpiredSessions()\n if (count === 0) {\n wire.scheduledTask.skip('No expired sessions') // throws — nothing below runs\n }\n await db.deleteExpiredSessions()\n logger.info(`Cleaned ${count} expired sessions`)\n },\n})\n\nexport const syncInventory = pikkuVoidFunc({\n title: 'Sync Inventory',\n func: async ({ inventoryApi, db, logger }) => {\n const updates = await inventoryApi.getChanges()\n await db.applyInventoryUpdates(updates)\n logger.info(`Synced ${updates.length} inventory changes`)\n },\n})\n\n// wirings/scheduler.wiring.ts\nwireScheduler({\n name: 'dailySummary',\n schedule: '0 9 * * *',\n func: dailySummary,\n})\nwireScheduler({\n name: 'cleanupExpired',\n schedule: '0 */6 * * *',\n func: cleanupExpired,\n})\nwireScheduler({\n name: 'syncInventory',\n schedule: '*/15 * * * *',\n func: syncInventory,\n})\n```\n", "pikku-deploy-azure/SKILL.md": "---\nname: pikku-deploy-azure\ndescription: >-\n Use when deploying a Pikku app to Azure Functions. Covers createAzureHandler for HTTP, storage\n queue and timer triggers, plus AzInvocationLogger and PikkuAZTimerRequest. TRIGGER when: user\n asks about Azure Functions, Azure deployment, or @pikku/azure-functions. DO NOT TRIGGER when:\n user asks about AWS Lambda (use pikku-deploy-lambda) or Cloudflare Workers (use\n pikku-deploy-cloudflare).\n---\n\n# Pikku Azure Functions Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/azure-functions` provides Azure Functions runtime adapters for Pikku.\n\n## Installation\n\n```bash\nyarn add @pikku/azure-functions @azure/functions\n```\n\n## API Reference\n\nExported from `@pikku/azure-functions`:\n\n- `createAzureHandler(factories, handlerTypes)` — the entry point. Returns\n `{ http?, queue?, timer? }` for the handler types you ask for.\n- `createAzureWorkerHandler(factories)` — `createAzureHandler(factories, ['fetch'])`.\n- `createAzureWebSocketHandler(factories)` — **a stub**: its `negotiate` always\n answers `501 WebSocket via Azure Web PubSub not yet implemented`. Channels do\n not work on Azure yet; do not plan a deployment around it.\n- `AzInvocationLogger` — the logger. Note the name: there is no\n `PikkuAzFunctionsLogger`.\n- `PikkuAZTimerRequest` — `new PikkuAZTimerRequest(context, data)`, a\n `PikkuRequest` carrying the data. The context argument is accepted and\n ignored.\n- `AzureQueueService`, `AzureDeploymentService`.\n\n`factories` is `{ createConfig, createSingletonServices, createPlatformServices? }`.\nServices are built from `process.env` and cached in module scope across\ninvocations of the same instance.\n\n## Usage Patterns\n\n### Registering handlers\n\n```typescript\nimport { app } from '@azure/functions'\nimport { createAzureHandler } from '@pikku/azure-functions'\nimport { createConfig, createSingletonServices } from './services.js'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst handlers = createAzureHandler(\n { createConfig, createSingletonServices },\n ['fetch', 'queue', 'scheduled']\n)\n\napp.http('api', {\n methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],\n route: '{*path}',\n handler: handlers.http as any,\n})\n\napp.storageQueue('queue', {\n queueName: 'my-queue',\n connection: 'AzureWebJobsStorage',\n handler: handlers.queue as any,\n})\n\napp.timer('scheduler', {\n schedule: '0 */5 * * * *',\n handler: handlers.timer as any,\n})\n```\n\nNote the key names: `handlerTypes` uses **`scheduled`**, but the handler it\nreturns is **`timer`**.\n\n### HTTP\n\nThe handler buffers the whole body, converts to a standard `Request`, and\nreturns the response body as **text** — a streaming or binary response is\nflattened. It takes no `RunHTTPWiringOptions`, so there is no `maxBodySize` or\n`respondWith404` here; Azure's own request limits are the bound. A thrown error\nis logged to `console.error` and whatever the response already holds is\nreturned.\n\n### Queue\n\nThe queue name comes from the message's own `queueName`, falling back to\n`context.triggerMetadata.queueTrigger` and then `'unknown'` — a name that does\nnot match a wired queue means the job has no handler. `attemptsMade` is read\nfrom `dequeueCount`, and `waitForCompletion` throws: Azure Storage Queues are\nfire-and-forget. A failing job throws out of the handler, so retries and the\npoison queue are governed by `host.json`, not by Pikku.\n\nProducer side, `AzureQueueService(connectionString?)` falls back to\n`AzureWebJobsStorage` and throws at construction if neither is set. Messages are\nbase64-encoded (Azure requires it), `delay` is milliseconds mapped to\n`visibilityTimeout` in whole seconds capped at 7 days, `supportsResults` is\n`false` and `getJob()` always throws. The queue name is remapped through\n`AZURE_QUEUE_NAME_<SCREAMING_SNAKE>` when that variable exists, otherwise used\nas-is.\n\n### Timer\n\nThe timer handler runs **every** scheduled task registered in the bundle,\nignoring both the `Timer` argument and each task's own cron expression. Unlike\nthe Lambda equivalent it does not catch per-task failures, so the first task\nthat throws aborts the ones after it — keep one schedule per function app, or\nguard the task bodies yourself.\n\n### Logging\n\n`new AzInvocationLogger(context)` forwards to the invocation context's\n`info`/`warn`/`error`/`debug`/`trace`. `setLevel()` is a **no-op**: every level\nis emitted and filtering has to be done in Azure's own logging configuration.\n", "pikku-deploy-cloudflare/SKILL.md": "---\nname: pikku-deploy-cloudflare\ndescription: >-\n Use when deploying a Pikku app to Cloudflare Workers. Covers HTTP fetch handler, scheduled\n tasks, and WebSocket via Durable Objects. TRIGGER when: code imports @pikku/cloudflare, user\n mentions Cloudflare Workers deployment, or worker entry uses ExportedHandler/wrangler.toml. DO\n NOT TRIGGER when: just defining functions/wirings without Cloudflare-specific code.\ninstallGroups: [fabric]\n---\n\n# Pikku Cloudflare Workers Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n```bash\nyarn add @pikku/cloudflare\n```\n\n## Worker Entry\n\n`@pikku/cloudflare` ships the handler factories the deploy codegen emits — use\nthem rather than hand-rolling an `ExportedHandler`. Each returns a\n`WorkerEntrypoint` class that sets services up on every invocation (cached after\nthe first) and adds an RPC-callable `runRpc(name, args)`:\n\n```typescript\nimport { createCloudflareHandler } from '@pikku/cloudflare'\nimport { createConfig, createSingletonServices } from './services.js'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nexport default createCloudflareHandler(\n { createConfig, createSingletonServices },\n ['fetch', 'scheduled']\n)\n```\n\n| Factory | For |\n| --- | --- |\n| `createCloudflareHandler(factories, handlerTypes)` | combined `fetch`/`queue`/`scheduled` |\n| `createCloudflareWorkerHandler(factories)` | HTTP / agent / RPC / workflow-orchestrator units |\n| `createCloudflareCronHandler(factories)` | cron units |\n| `createCloudflareQueueHandler(factories)` | queue-consumer and workflow-step units |\n| `createCloudflareMCPHandler(factories)` | MCP units (same HTTP transport) |\n| `createCloudflareWebSocketHandler(factories)` | channel units, delegating to the DO |\n\n`factories` is `{ createConfig, createSingletonServices, createPlatformServices? }`.\n\n## Service Setup\n\nCloudflare passes env bindings per-request, so services are built from `env`\nrather than at module load. `setupServices(env, factories)` is exported from\n`@pikku/cloudflare` and is what the factories call:\n\n```typescript\nimport { setupServices } from '@pikku/cloudflare'\n\nconst services = await setupServices(env, {\n createConfig,\n createSingletonServices,\n})\n```\n\n**Do not hand-roll this.** Beyond building `LocalVariablesService` /\n`LocalSecretService` and caching the result, it calls `setSingletonServices()` —\nand the core runners (`fetchData`, `runQueueJob`, `runScheduled`) resolve\nservices through that global slot, *not* through the value you were returned. A\nsetup function that only returns the services leaves every request throwing\n\"Singleton services not initialized\" as a CF `1101`. It also stashes the env via\n`setCloudflareEnv`, which `getCloudflareEnv()` reads for bindings.\n\n## HTTP\n\n`runFetch(request, websocketHibernationServer?, options?)`:\n\n- A `GET` with `Upgrade: websocket` is routed to the hibernation server. Without\n one passed in it answers **426**, so a channel worker that forgets the second\n argument fails every upgrade while plain HTTP keeps working.\n- `CF-Ray` becomes the traceId when present, so a Cloudflare trace and a Pikku\n trace line up without extra wiring.\n- `options.exposeErrors` defaults to **`false`** — error detail is withheld from\n responses unless you opt in.\n\n## Scheduled Tasks\n\n`runScheduled(controller)` matches registered tasks against\n`controller.cron` and **returns after the first match**. Two tasks sharing one\ncron expression means only one of them ever runs — give each its own expression,\nor invoke `runScheduledTask({ name })` per task yourself.\n\n## WebSocket (Durable Objects)\n\nThe ready-made DO class is exported; re-export it under the binding name and\npoint the worker at it:\n\n```typescript\nexport { PikkuWebSocketHibernationServer as WebSocketHibernationServer } from '@pikku/cloudflare'\nexport default createCloudflareWebSocketHandler({\n createConfig,\n createSingletonServices,\n})\n```\n\nSubclass `CloudflareWebSocketHibernationServer` only when you need something\n`getParams()` cannot express — it is abstract with one method returning\n`{ singletonServices, createWireServices? }`. The channel store\n(`CloudflareWebsocketStore` over the DO's own storage), the event hub and the\nchannel handler factory are all built by the base class; do not supply them.\n\nThe router looks up the DO through the **`WEBSOCKET_HIBERNATION_SERVER`**\nbinding and answers `503` naming it if the binding is missing, so declare it in\n`wrangler.toml` under exactly that name.\n\nA throw during `onConnect` closes the socket with `1008` and answers `403\nForbidden` with a deliberately generic body — an auth denial and a genuine fault\nlook identical to the client. The real reason is on the logger, so read the\nworker logs rather than the status code.\n", "pikku-deploy-express/SKILL.md": "---\nname: pikku-deploy-express\ndescription: >-\n Use when deploying a Pikku app with Express. Covers PikkuExpressServer standalone and\n pikkuExpressMiddleware for existing Express apps. TRIGGER when: code imports @pikku/express or\n @pikku/express-middleware, user mentions Express deployment, or start.ts creates a\n PikkuExpressServer. DO NOT TRIGGER when: just defining functions/wirings without\n Express-specific code.\n---\n\n# Pikku Express Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## Standalone Server\n\n```bash\nyarn add @pikku/express\n```\n\n```typescript\nimport { PikkuExpressServer } from '@pikku/express'\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst appServer = new PikkuExpressServer(\n { ...config, port: 4002, hostname: 'localhost' },\n singletonServices.logger\n)\nappServer.enableExitOnSigInt()\nawait appServer.init()\nawait appServer.start()\n```\n\n**Constructor:** `new PikkuExpressServer(config, logger)`\n\n**Config extends CoreConfig with:**\n\n- `port: number`\n- `hostname: string`\n- `healthCheckPath?: string`\n- `limits?: Partial<Record<string, string>>`\n- `content?: LocalContentConfig` (for static assets / file uploads)\n\n**Methods:**\n\n- `init(httpOptions?: RunHTTPWiringOptions): Promise<void>` — installs the body parsers, the cookie parser and the Pikku middleware\n- `start(): Promise<void>` — Start listening\n- `stop(): Promise<void>` — Graceful shutdown; throws if the server was never started\n- `enableExitOnSigInt(): Promise<void>` — SIGINT handler: stops the singleton services, then the server, then exits 0\n- `enableCors(options): void` — Enable CORS\n- `enableStaticAssets(): void` — serve `content.localFileUploadPath` under `content.assetUrlPrefix`\n- `enableReaper(): void` — a `PUT /reaper/*path` upload sink for local development, path-traversal checked and bounded by `content.sizeLimit` (default `1mb`)\n- `getHttpServer(): Server` — the underlying `http.Server`, e.g. to attach a WebSocket server; throws before `start()`\n\n`enableStaticAssets` and `enableReaper` both throw when `content` is unset.\n\n**Property:** `app: Express` — Direct access to Express instance for custom middleware.\n\n### Ordering, and what `init` installs for you\n\nThe health check is registered in the **constructor**, so it answers before any\nmiddleware you add and cannot be wrapped in auth. It defaults to\n`/health-check`; override with `healthCheckPath`.\n\nEverything else is installed by `init()`: `express.json`, `express.text` (for\n`text/xml`), `express.urlencoded`, `cookie-parser`, then the Pikku middleware.\nCall `enableCors` **before** `init` if you want CORS applied to Pikku's routes.\n\nExpress buffers the body before Pikku sees it, so the parser limit is the only\nplace an oversized request can actually be stopped. `httpOptions.maxBodySize`\ntherefore feeds those parser limits, with an explicit `config.limits` entry\n(`json` / `xml` / `urlencoded`) still winning. Everything defaults to `1mb`.\n\n`init` passes `logRoutes: true` and `loadSchemas: true` by default; your\n`httpOptions` spread over them, so you can turn either off.\n\n## Middleware (existing Express app)\n\n```bash\nyarn add @pikku/express-middleware\n```\n\n```typescript\nimport express from 'express'\nimport { pikkuExpressMiddleware } from '@pikku/express-middleware'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst app = express()\napp.use(express.json())\napp.use(cookieParser())\napp.use(\n pikkuExpressMiddleware({\n logger: singletonServices.logger,\n logRoutes: true,\n loadSchemas: true,\n // plus any RunHTTPWiringOptions: maxBodySize, respondWith404, coerceDataFromSchema\n })\n)\n```\n\nOptions beyond `logger` are all optional: `logRoutes` logs the wiring table once\nat startup, `loadSchemas` compiles every schema up front, and the rest are\n`RunHTTPWiringOptions` passed through per request.\n\nOn your own app **you** own the parser stack — the middleware reads\n`req.body`, so a body parser and `cookie-parser` must be registered before it,\nand `maxBodySize` alone will not stop an oversized request that your parser\nalready accepted. Unmatched requests fall through to `next()` (unless\n`respondWith404` is set), so Pikku's routes coexist with your existing ones;\na streaming response is the exception and does not call `next()`.\n", "pikku-deploy-fastify/SKILL.md": "---\nname: pikku-deploy-fastify\ndescription: >-\n Use when deploying a Pikku app with Fastify. Covers PikkuFastifyServer standalone and\n pikkuFastifyPlugin for existing Fastify apps. TRIGGER when: code imports @pikku/fastify or\n @pikku/fastify-plugin, user mentions Fastify deployment, or start.ts creates a\n PikkuFastifyServer. DO NOT TRIGGER when: just defining functions/wirings without\n Fastify-specific code.\n---\n\n# Pikku Fastify Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## Standalone Server\n\n```bash\nyarn add @pikku/fastify\n```\n\n```typescript\nimport { PikkuFastifyServer } from '@pikku/fastify'\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst appServer = new PikkuFastifyServer(\n { ...config, hostname: 'localhost', port: 4002 },\n singletonServices.logger\n)\nappServer.enableExitOnSigInt()\nawait appServer.init()\nawait appServer.start()\n```\n\n**Constructor:** `new PikkuFastifyServer(config, logger)`\n\n**Config extends CoreConfig with:** `port`, `hostname`, `healthCheckPath?`\n\n**Methods:** `init(httpOptions?: RunHTTPWiringOptions)`, `start()`, `stop()`, `enableExitOnSigInt()`\n\n**Property:** `app: FastifyInstance` — Direct access to Fastify instance.\n\n`enableCors` exists on the class but **throws `Method not implemented.`** — unlike\nthe Express server. Register `@fastify/cors` on `app` yourself before `init()`.\n\nUnlike the Express server, the health check is registered by `init()`, not the\nconstructor, so nothing answers before `init` runs. `init` also passes\n`logRoutes: true` and `loadSchemas: true`, which your `httpOptions` can override.\nThe Fastify instance is constructed with no options; reach for the plugin package\nif you need `Fastify({ … })` of your own.\n\n## Plugin (existing Fastify app)\n\n```bash\nyarn add @pikku/fastify-plugin\n```\n\n```typescript\nimport Fastify from 'fastify'\nimport pikkuFastifyPlugin from '@pikku/fastify-plugin'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst app = Fastify()\napp.register(pikkuFastifyPlugin, {\n pikku: {\n logger: singletonServices.logger,\n logRoutes: true,\n loadSchemas: true,\n // plus any RunHTTPWiringOptions: maxBodySize, respondWith404, …\n },\n})\n```\n\nEvery option other than `logger` is optional, and the rest of the `pikku` object\nis `RunHTTPWiringOptions` passed straight through.\n\nThe plugin registers a catch-all `fastify.all('/*')`, so mount it on a\n[Fastify prefix](https://fastify.dev/docs/latest/Reference/Plugins/) if the app\nhas routes of its own to keep.\n\nFastify buffers the body itself, so its `bodyLimit` is where an oversized request\nis stopped. `maxBodySize` sets it — and left unset, Fastify's stricter 1MB default\nstands rather than being loosened to Pikku's 10MB fallback.\n", "pikku-deploy-lambda/SKILL.md": "---\nname: pikku-deploy-lambda\ndescription: >-\n Use when deploying a Pikku app to AWS Lambda. Covers HTTP handlers, scheduled tasks, SQS queue\n workers, WebSocket via API Gateway, and cold start caching. TRIGGER when: code imports\n @pikku/lambda, user mentions Lambda/serverless/AWS deployment, or handler files export\n Lambda-typed functions. DO NOT TRIGGER when: just defining functions/wirings without\n Lambda-specific code.\n---\n\n# Pikku AWS Lambda Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n```bash\nyarn add @pikku/lambda\n```\n\n## Cold Start Pattern\n\nCache singleton services across Lambda invocations:\n\n```typescript\n// cold-start.ts\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nlet singletonServices: SingletonServices | undefined\n\nexport const coldStart = async () => {\n if (!singletonServices) {\n const config = await createConfig()\n singletonServices = await createSingletonServices(config)\n }\n return singletonServices\n}\n```\n\nIf the deploy codegen generated your handlers, this caching is already done for\nyou by the factories in `@pikku/lambda` — `createLambdaHandler(factories,\nhandlerTypes)`, `createLambdaWorkerHandler(factories)` and\n`createLambdaWebSocketHandler(factories)`. They build `variables`/`secrets` from\n`process.env`, cache the singleton services in module scope, and return the\nnamed exports (`handler`, `queue`, `scheduled`, or `connect`/`disconnect`/\n`default`) that `serverless.yml` references. Hand-written handlers are for cases\nthe codegen does not cover.\n\n## HTTP Handler\n\nPick the entry point that matches the API Gateway payload version — they take\ndifferent event types and are not interchangeable:\n\n```typescript\nimport type { APIGatewayEvent } from 'aws-lambda'\nimport { runFetch } from '@pikku/lambda/http' // REST API / payload v1\n\nexport const httpRoute = async (event: APIGatewayEvent) => {\n await coldStart()\n return await runFetch(event)\n}\n```\n\n```typescript\nimport type { APIGatewayProxyEventV2 } from 'aws-lambda'\nimport { runFetchV2 } from '@pikku/lambda/http' // HTTP API / payload v2\n\nexport const httpRoute = async (event: APIGatewayProxyEventV2) => {\n await coldStart()\n return await runFetchV2(event)\n}\n```\n\nBoth answer `OPTIONS` themselves before Pikku's wirings run, so a preflight\nnever reaches your middleware. Only `runFetchV2` echoes the request `Origin`\ninto `Access-Control-Allow-Origin`; `runFetch` sets allowed headers and methods\nbut **no origin header at all**, so v1 preflights fail in the browser unless\nAPI Gateway or a CloudFront layer adds one.\n\nThey also differ on failure: `runFetchV2` logs and returns a JSON `500`, while\n`runFetch` swallows the error and returns whatever status the response already\ncarried.\n\nNeither takes `RunHTTPWiringOptions` — there is no `maxBodySize` or\n`respondWith404` knob here; API Gateway's own payload limit is the bound.\n\n## Scheduled Tasks\n\n```typescript\nimport type { ScheduledHandler } from 'aws-lambda'\nimport { runLambdaScheduled } from '@pikku/lambda/scheduled'\n\nexport const scheduled: ScheduledHandler = async (event) => {\n await coldStart()\n await runLambdaScheduled(event)\n}\n```\n\n`runLambdaScheduled` runs **every** scheduled task registered in the bundle,\neach with its own `cron-<uuid>` traceId, and logs rather than rethrows a task\nfailure — so one bad task cannot fail the invocation or stop the others. The\nevent itself is ignored; which tasks run is decided by what the unit bundled,\nnot by which EventBridge rule fired.\n\nReach for `runScheduledTask({ name })` from `@pikku/core/scheduler` directly\nonly when one Lambda genuinely bundles several tasks that must fire on separate\nschedules.\n\n## SQS Queue Worker\n\n```typescript\nimport type { SQSHandler } from 'aws-lambda'\nimport { runSQSQueueWorker } from '@pikku/lambda/queue'\n\nexport const mySQSWorker: SQSHandler = async (event) => {\n const { logger } = await coldStart()\n return runSQSQueueWorker(logger, event)\n}\n```\n\nThe worker returns an `SQSBatchResponse` listing the failed messages in\n`batchItemFailures`, which SQS only honours when the event source mapping has\n**`ReportBatchItemFailures`** enabled. Without it the whole batch is retried\nwhen any one message fails, so successfully processed jobs run twice.\n\nRecords are processed in parallel, and the queue name is taken from the last\nsegment of `eventSourceARN` — it must match the name the worker was wired under.\nA `QueueJobDiscardedError` counts as success (no retry); anything else is\nreported as a failed item.\n\n`waitForCompletion` throws on an SQS job: the transport is fire-and-forget.\n\nOn the producer side, `SQSQueueService` resolves each queue URL from the\nconstructor's `queueUrlMap` first, then from\n`SQS_QUEUE_URL_<SCREAMING_SNAKE_NAME>`, and throws naming the missing variable\nif neither has it. `supportsResults` is `false` and `getJob()` always throws —\nuse BullMQ or PgBoss if you need results. `delay` is milliseconds, rounded up to\nwhole seconds and capped at SQS's 900s ceiling.\n\n## WebSocket (API Gateway v2)\n\n```typescript\nimport {\n connectWebsocket,\n disconnectWebsocket,\n processWebsocketMessage,\n LambdaEventHubService,\n} from '@pikku/lambda/websocket'\n\nconst params = async (event) => {\n const { channelStore } = await coldStart()\n return { channelStore }\n}\n\nexport const connectHandler = async (event) =>\n await connectWebsocket(event, await params(event))\n\nexport const disconnectHandler = async (event) =>\n await disconnectWebsocket(event, await params(event))\n\nexport const defaultHandler = async (event) =>\n await processWebsocketMessage(event, await params(event))\n```\n\nAll three take the same `{ channelStore }` and **return a complete\n`APIGatewayProxyResult`** — return it. Discarding `connectWebsocket`'s result\nand answering a hardcoded `200` accepts every connection, including the ones\nyour channel's auth rejected.\n\n`channelStore` (e.g. `PgChannelStore`) must be a real shared store: each route\nis a separate invocation, so nothing survives in memory between `$connect` and\n`$default`.\n\n`LambdaEventHubService` handles cross-connection messaging and takes\n`(logger, event, channelStore, eventHubStore)` — the `event` is needed to derive\nthe API Gateway Management endpoint, so it is constructed per invocation, not\nonce at cold start. It also needs an `EventHubStore` alongside the channel\nstore.\n\nTwo behaviours to design around: **binary payloads throw** (`Binary data is not\nsupported on serverless lambdas`), and any `PostToConnection` failure removes\nthe connection from the channel store — a transient error drops a live client,\nnot just a stale one.\n", "pikku-deploy-nextjs/SKILL.md": "---\nname: pikku-deploy-nextjs\ndescription: >-\n Use when deploying a Pikku app with Next.js. Covers API route handlers, server-side data\n fetching, and RPC calls from Server Components. TRIGGER when: code imports @pikku/next, user\n mentions Next.js integration, or app/api route files use pikkuAPIRequest. DO NOT TRIGGER when:\n just defining functions/wirings without Next.js-specific code.\n---\n\n# Pikku Next.js Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n```bash\nyarn add @pikku/next\n```\n\n## API Route Handler\n\nThe CLI generates a typed wrapper. Use it in a catch-all route:\n\n```typescript\n// app/api/[...route]/route.ts\nimport { pikkuAPIRequest } from '@/pikku-nextjs.gen.js'\n\nexport const GET = pikkuAPIRequest\nexport const POST = pikkuAPIRequest\nexport const PUT = pikkuAPIRequest\nexport const PATCH = pikkuAPIRequest\nexport const DELETE = pikkuAPIRequest\n```\n\n`pikkuAPIRequest` strips a leading `/api` from the pathname before routing, so\nwirings are declared as `/todos`, not `/api/todos`, even though the route file\nlives under `app/api`. Turn that off with `removeAPIPrefix(false)` from the same\ngenerated file if your wirings really do carry the prefix.\n\nIt takes `(req, context)` to match Next's handler signature but ignores the\ncontext — Pikku routes from the URL, so the catch-all segment name is yours to\nchoose. It also passes no `RunHTTPWiringOptions`: to set `maxBodySize` or\n`respondWith404` you need your own handler over `new PikkuNextJS(...)` calling\n`apiRequest(req, options)`.\n\n## Server-Side Data Fetching\n\nUse the generated `pikku()` helper in Server Components or Server Actions:\n\n```typescript\nimport { pikku } from '@/pikku-nextjs.gen.js'\n\nconst { get, post, patch, del, rpc, staticGet, staticPost, staticRPC } = pikku()\n\n// Dynamic (reads headers/cookies — requires request context)\nconst todos = await get('/todos')\nconst created = await post('/todos', { title: 'Buy milk' })\n\n// Static (no request context — suitable for precompile/ISR)\nconst config = await staticGet('/config')\n\n// RPC calls\nconst result = await rpc('calculateTax', { amount: 100, region: 'US' })\n```\n\n**Dynamic vs Static:**\n\n- `get`, `post`, `patch`, `del`, `rpc` — read `next/headers` cookies and headers,\n so they force the component dynamic\n- `staticGet`, `staticPost`, `staticRPC` — no request context, safe for\n precompile/ISR\n\nThe static variants pass `skipUserSession: true`, so a wiring that expects a\nsession sees none. That is the real difference — not just where they can run.\nThere is no `staticPatch` or `staticDel`; a mutation at build time is not a\nthing the generated client offers.\n\nBoth paths run with `bubbleErrors: true`, so a failing wiring **throws** in your\nServer Component rather than resolving to an error status. Wrap the call, or let\nthe Next.js error boundary take it.\n\n## How It Works\n\n`PikkuNextJS` lazy-initializes on first request:\n\n```typescript\nimport { PikkuNextJS } from '@pikku/next'\n\nconst pikku = new PikkuNextJS(createConfig, createSingletonServices)\n```\n\n**Constructor:** `new PikkuNextJS(createConfig | undefined, createSingletonServices)`\n\nBoth arguments are positional and `createConfig` is only optional in the sense\nthat passing `undefined` substitutes an empty config —\n`createSingletonServices` is required.\n\nInitialization is memoized on a promise, so concurrent first requests share one\nsetup; a failed setup clears the promise, so the next request retries rather\nthan caching the failure forever.\n\nThe generated `pikku-nextjs.gen.ts` wraps this with full type safety from your\nroute definitions.\n\n## Related exports\n\n- **`PikkuNextJSWorkerRPC({ fetcher })`** — same surface as `PikkuNextJS`, but\n every call is dispatched through a `Fetcher` (a Cloudflare service binding, a\n local HTTP client, a fabric dispatcher) instead of loading function code\n in-process. Use it to keep functions out of the SSR bundle. A non-2xx\n response throws with the status and body text.\n- **`toNextJsAuthHandler(auth)`** — wraps a better-auth instance or handler\n function for an auth route. The three-argument form\n `(pikkuAuthFactory, createConfig, createSingletonServices)` resolves a Pikku\n better-auth factory lazily; it throws if you pass `createConfig` without\n `createSingletonServices`. `nextCookies` is re-exported alongside it.\n", "pikku-deploy-uws/SKILL.md": "---\nname: pikku-deploy-uws\ndescription: >-\n Use when deploying a Pikku app with uWebSockets.js. Covers PikkuUWSServer with built-in HTTP and\n WebSocket support, and pikkuWebsocketHandler for standalone ws library. TRIGGER when: code\n imports @pikku/uws or @pikku/ws, user mentions uWebSockets or high-performance server, or\n start.ts creates a PikkuUWSServer. DO NOT TRIGGER when: just defining functions/wirings without\n uWS-specific code.\n---\n\n# Pikku uWebSockets.js Deployment\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nHighest-throughput option among Pikku's runtimes. Handles both HTTP and WebSocket automatically.\n\n```bash\nyarn add @pikku/uws\n```\n\n```typescript\nimport { PikkuUWSServer } from '@pikku/uws'\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst appServer = new PikkuUWSServer(\n { ...config, hostname: 'localhost', port: 4002 },\n singletonServices.logger\n)\nappServer.enableExitOnSigInt()\nawait appServer.init()\nawait appServer.start()\n```\n\n**Constructor:** `new PikkuUWSServer(config, logger)`\n\n**Config extends CoreConfig with:** `port`, `hostname`, `healthCheckPath?`\n\n**Methods:** `init(httpOptions?: RunHTTPWiringOptions)`, `start()`, `stop()`, `enableExitOnSigInt()`\n\n**Property:** `app: uWS.App` — Direct access to uWebSockets app instance.\n\n### What the server does and does not give you\n\n`init()` registers three things in order: the health check (`healthCheckPath`,\ndefault `/health-check`), a catch-all `app.any('/*')` HTTP handler, and a\ncatch-all `app.ws('/*')` websocket handler. Nothing is registered by the\nconstructor, so nothing answers before `init` runs.\n\n**There is no `enableCors`, no static assets and no `content` support** — unlike\nthe Express server. The class is explicitly a prototyping convenience; for\nanything that needs extra handlers, use `@pikku/uws-handler` directly and treat\n`pikku-uws-server.ts` as the template (that is what its own JSDoc says).\n\n`httpOptions` reaches the HTTP handler only. The websocket handler is\nconstructed with a fixed `{ logger, logRoutes: true }`, so per-request options\ndo not apply to the upgrade path. `loadSchemas` is also never passed by the\nserver, so schemas compile lazily on first use rather than at startup — pass\n`loadSchemas: true` in `httpOptions` if you want the startup cost paid up front.\n\n`stop()` closes the listen socket and then waits a fixed 2 seconds for\nconnections to drain. Called before `start()`, it throws a bare **string**, not\nan `Error`, so `catch (e) { e.message }` reads `undefined`.\n\n### Body limits\n\nuWS hands over raw chunks with no limit of its own, so the handler counts the\nbytes itself. A request over `maxBodySize` (default `DEFAULT_MAX_BODY_SIZE`) is\nanswered `413` with a `PayloadTooLargeError` body, and the chunks are dropped\nrather than concatenated — an oversized request never accumulates in memory. A\n`content-length` header that already exceeds the limit short-circuits before any\ndata arrives.\n\n### Handlers directly (own uWS app)\n\n```typescript\nimport { pikkuHTTPHandler, pikkuWebsocketHandler } from '@pikku/uws-handler'\n\napp.any('/*', pikkuHTTPHandler({ logger, logRoutes: true, loadSchemas: true }))\napp.ws('/*', pikkuWebsocketHandler({ logger, logRoutes: true }))\n```\n\nBoth take `{ logger, logRoutes?, loadSchemas? } & RunHTTPWiringOptions`.\n\n## WebSocket Standalone (ws library)\n\nFor WebSocket-only servers using the `ws` library:\n\n```bash\nyarn add @pikku/ws\n```\n\n```typescript\nimport { pikkuWebsocketHandler } from '@pikku/ws'\nimport { stopSingletonServices } from '@pikku/core'\nimport { Server } from 'http'\nimport { WebSocketServer } from 'ws'\nimport './.pikku/pikku-bootstrap.gen.js'\n\nconst server = new Server()\nconst wss = new WebSocketServer({ noServer: true })\n\npikkuWebsocketHandler({\n server,\n wss,\n logger: singletonServices.logger,\n})\n\nserver.listen(4002, 'localhost', () => {\n console.log('Server running at http://localhost:4002/')\n})\n\nprocess.on('SIGINT', async () => {\n await stopSingletonServices()\n wss.close()\n server.close()\n process.exit(0)\n})\n```\n\n`pikkuWebsocketHandler` takes `{ server, wss, logger, logRoutes?, loadSchemas? }`\nplus `RunHTTPWiringOptions`, and there is no server class in `@pikku/ws` — the\nhandler attaches to a `Server` you own.\n\n`noServer: true` is required, not stylistic: the handler listens for the HTTP\nserver's own `upgrade` event, opens the channel (running middleware and auth\nfirst), and only then calls `wss.handleUpgrade`. A `WebSocketServer` bound to\nthe server would take the socket before any of that ran. An upgrade the channel\nrejects gets the socket destroyed, and an auth failure is written as a real HTTP\nresponse on the raw socket rather than a silent drop.\n", "pikku-deps/SKILL.md": "---\nname: pikku-deps\ndescription: >-\n Use for the Pikku dependency security audit: the `pikku audit` CLI command, the\n `.pikku/audit.json` artifact, the `SecurityAuditReport` type in @pikku/core, and the console\n Security screen (getSecurityAudit / runSecurityAudit / updateDependency + SecurityAuditView).\n TRIGGER when: user asks about `pikku audit`, dependency vulnerabilities/advisories, outdated\n dependencies, the Security screen/page in the console, updating a vulnerable dependency, or\n reading/rendering audit.json. DO NOT TRIGGER when: user asks about authentication/sessions/JWT\n (use pikku-security), permissions (use pikku-permissions), or secrets/env vars (use\n pikku-config).\ninstallGroups: [core]\n---\n\n# Pikku Dependency Audit\n\n## Agent Operating Procedure\n\n1. The audit is a generated artifact, not live state. `pikku audit` writes the\n normalised report to `.pikku/audit.json` (config `outDir`), so it rides the\n same meta pipeline as every other codegen output — uploaded on deploy,\n readable by the console addon and any tooling. Read it via\n `metaService.readFile('audit.json')`, never by shelling out to the package\n manager from a function.\n2. One source of truth for the shape: `SecurityAuditReport` (and\n `SecurityAuditIssue` / `SecurityAuditUpdate` / `SecurityAuditSummary` +\n `SecuritySeverity` / `SecurityUpdateLevel`) are exported from **@pikku/core**.\n The CLI writes it, the addon reads it, the UI renders it — never redeclare\n the type at a call site.\n3. Validate with `pikku all --tsc` after changes — it type-checks and **fails on\n type errors**, like any real build gate. Separately, `pikku audit` never fails\n a build: advisories are informational, and a missing/failed audit yields an\n empty-but-valid report.\n\n## The `pikku audit` command\n\n- `pikku audit` — reports **security advisories** only.\n- `pikku audit --outdated` — also reports **available dependency updates**.\n- Package-manager detection is by **lockfile**, walking up to 12 levels to the\n workspace root, checking in this order: `bun.lock`/`bun.lockb`,\n `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`. A project with several\n lockfiles resolves as bun. Only **bun** runs a real audit (`bun audit --json` +\n `bun outdated`, normalised into one `SecurityAuditReport` with per-severity /\n per-update-level counts). Other PMs are detected but **stubbed** with a `note`\n field until their shapes are normalised — issues/updates come back empty.\n- `bun audit` exits non-zero when it *finds* advisories but still writes the\n payload to stdout, so a non-zero exit **with output** is data. A non-zero exit\n with **no** output — or a launch failure, timeout, or a blown 32MB buffer —\n throws, precisely so a failed run can't masquerade as \"0 advisories\".\n\n## Console integration (@pikku/addon-console)\n\nThree RPCs, all reading/writing the same artifact via the meta service. Shared\nspawn/read helpers live in `lib/audit-exec.ts` (`readAuditReport`,\n`runPikkuAudit`, `spawnProcess`, `findBin`), alongside `lib/find-project-root.ts`\nand `lib/resolve-package-manager.ts` (`resolvePackageManager`, `installArgs`,\n`execPrefix`) — reuse them, don't re-implement. `resolvePackageManager` reads\npackage.json's corepack `packageManager` field first and only falls back to\nlockfiles, because that field states intent before a lockfile exists and a\nproject can carry a stale one from another tool. Guessing wrong is not a soft\nfailure: the spawn dies with `Executable not found in $PATH`.\nLike every console RPC these require an **authenticated session** (the console\nis admin-only), so the host must have Better Auth wired — see `pikku-better-auth`.\n\n- `getSecurityAudit` — reads `.pikku/audit.json`, returns the report (or `null`).\n- `runSecurityAudit` — runs `pikku audit --outdated` server-side (regenerates the\n artifact) then returns the fresh report. Same shape as the Run Tests action.\n- `updateDependency({ package, version })` — bumps the package in `package.json`\n (preserving the `^`/`~` range prefix), runs `bun install`, re-audits, and\n returns the fresh report. Throws if the package is not a direct dependency.\n NOTE: `bun install` must be scoped to a standalone project — do not run it\n inside a yarn/bun monorepo member (it resolves the whole workspace).\n\n## Console UI (@pikku/console)\n\n- `SecurityPage` — the page: **Run audit** button (`lead`) + responsive\n `ShellHeader` (structured `search` + `selection` for the Issues/Dependencies\n lens; never cram raw controls into the non-collapsing `filters`/`view` escape\n hatch). Empty state until an audit has run.\n- `SecurityAuditView` — exported presentational component. Two lenses\n (Issues grouped by severity; Dependencies table). Each finding row carries its\n actions **right-aligned in the row header** (`Accordion.Control` sibling, so a\n click acts instead of toggling): \"View advisory\" + a per-finding\n **remediation slot**.\n- `renderRemediation({ pkg, version, issue })` — the extension seam. OSS default\n is `UpdateDependencyButton` (the free bump + `bun install`). Downstream\n consoles (Fabric) pass their own sandbox-verified action here — replace the\n action, keep the view.\n- Hooks: `useSecurityAudit` (read), `useRunSecurityAudit` (run),\n `useUpdateDependency` (bump). All are `useMutation`/`useQuery` — surface\n `mutation.error`, never hand-roll loading/error state or swallow the error.\n\n## Report shape (SecurityAuditReport)\n\n```ts\n{\n schemaVersion: number\n tool: string // e.g. 'bun'\n generatedAt: string // ISO timestamp\n note?: string // set when the audit could NOT run (unsupported PM);\n // render ONLY the note — never a reassuring \"no vulnerabilities\"\n summary: {\n totalIssues, critical, high, moderate, low: number // no `info` bucket\n totalUpdates, major, minor, patch: number\n }\n issues: SecurityAuditIssue[] // package, severity, title, advisoryId, url,\n // vulnerableVersions, cwe[], cvssScore, recommendedVersion\n updates: SecurityAuditUpdate[] // package, current, latest, level (major|minor|patch|unknown)\n}\n```\n\n`severity` is one of `critical | high | moderate | low | info`, but `summary`\nhas no `info` count — an informational advisory raises `totalIssues` without\nlanding in a severity bucket, so don't sum the four to get the total. On an\nissue, `url`, `cvssScore` and `recommendedVersion` are always present and\n**nullable** rather than optional: check for `null`, not `undefined`.\n\nWhen `note` is present the audit did not run — show only the note (an \"Audit not\nrun\" state), never the \"no known vulnerabilities / up to date\" copy.\n", "pikku-emails/SKILL.md": "---\nname: pikku-emails\ndescription: >-\n Use when working with Pikku's file-based email templates: authoring HTML/subject/text templates,\n locales, partials and theme, running `pikku emails generate`, and rendering/sending them through\n an EmailService. TRIGGER when: code uses renderEmailTemplate, EmailTemplateName, EmailService,\n SendTemplateEmailInput, LocalEmailService, or imports from .pikku/email/pikku-emails.gen.\n TRIGGER when: the project has an emails/ directory (templates/, locales/, partials/, theme.json)\n or emailTemplatesDir in pikku.config.json. TRIGGER when: user asks to add/edit a transactional\n email (verification, password reset, invitation, receipt), wire email sending, or translate an\n email. DO NOT TRIGGER when: user asks about i18n for the app UI (use pikku-i18n) or auth flows\n in general (use pikku-better-auth).\ninstallGroups: [core]\n---\n\n# Pikku Emails\n\nPikku compiles a directory of plain template files into a typed, dependency-free\nrenderer. `pikku emails generate` reads `emailTemplatesDir` and writes\n`.pikku/email/pikku-emails.gen.ts` (the `renderEmailTemplate` function + per-template\ntypes) and `pikku-emails-meta.gen.json`. Templates are authored as files; the\ngenerated output is never edited by hand.\n\n## Agent Operating Procedure\n\n1. Edit source files under `emailTemplatesDir` only. Never edit `.pikku/email/*`. If the\n directory does not exist yet, run `pikku emails init` rather than creating it by hand.\n2. After any change run `pikku emails generate` (it is also part of `prebuild`, usually\n `pikku bootstrap; pikku all; pikku emails generate`).\n3. Validate by importing `renderEmailTemplate` and rendering with sample data, or run the\n project's typecheck — the generated `data` type will flag missing/wrong variables.\n4. Fix the source cause; do not patch generated files or update hashes by hand.\n\n## Config\n\n```jsonc\n// pikku.config.json\n{\n \"emailTemplatesDir\": \"emails\", // relative to rootDir; omit to disable emails\n \"outDir\": \".pikku\" // gen lands in <outDir>/email/\n}\n```\n\nIf `emailTemplatesDir` is unset the command is a no-op — it logs\n`Skipping emails (set emailTemplatesDir in pikku.config.json to enable).` and exits\ncleanly, so a silent generate is a config problem, not a template problem.\n\n`pikku emails init` scaffolds the directory (starter locales, theme, partials and a\nhello-world template) **and** writes `emailTemplatesDir` into `pikku.config.json` for\nyou. Use it rather than hand-creating the tree; `--force` overwrites an existing\nscaffold.\n\n## Directory layout\n\n```text\nemails/\n theme.json # brand tokens: appName, fonts, colors\n locales/\n en.json # translation strings, nested namespaces\n de.json # one file per locale (filename = locale key)\n partials/\n layout.html # outer wrapper; must include {{content}}\n footer.html # reusable fragment, included with {{> footer}}\n templates/\n verify-email.html # body (required)\n verify-email.subject.txt # subject line (required)\n verify-email.text.txt # plain-text alternative (optional)\n```\n\nA template's **name** is its filename without the `.html` / `.subject.txt` / `.text.txt`\nsuffix (`verify-email` above). `html` and `subject` are required; `text` is optional and,\nwhen present, becomes the plain-text MIME part.\n\n## Templating syntax\n\nPlaceholders are `{{ ... }}`. Resolution order inside a template:\n\n- `{{appName}}` — from `data.appName`, falling back to `theme.appName`.\n- `{{theme.colors.accent}}`, `{{theme.fonts.body}}` — values from `theme.json`.\n- `{{t.verifyEmail.heading}}` — string from the active locale file (`locales/<locale>.json`).\n- `{{verifyUrl}}` — any other key is a **runtime variable**, supplied via `data`.\n- `{{> footer}}` — include a partial from `partials/`.\n- `{{content}}` / `{{subject}}` — only meaningful inside `partials/layout.html`\n (the rendered body and subject). `layout.html` wraps every template if present.\n\nLocale strings may themselves contain variables and partial-free placeholders, e.g.\n`\"subject\": \"{{inviterName}} invited you to join {{organizationName}}\"`. These are\nresolved in the same pass, so a subject of `{{t.invitation.subject}}` expands fully.\n\n## Typed variables (per template)\n\nThe generator extracts the runtime variables each template references and emits a typed\n`data` shape. Extraction is **scoped to the template**: it walks the template's\nhtml/subject/text, the partials it includes, and only the locale keys it actually\nreferences (transitively) — variables from unrelated locale entries do not leak in.\n\n```ts\nimport {\n renderEmailTemplate,\n type EmailTemplateName,\n type EmailTemplateVariables,\n} from './.pikku/email/pikku-emails.gen.js'\n\n// EmailTemplateVariables<'organization-invitation'> =\n// { appName?: ...; inviteUrl?: ...; inviterName?: ...; organizationName?: ... }\n```\n\nEvery extracted variable is emitted **optional** and typed `EmailTemplateValue`\n(`string | number | boolean | null | undefined | object | array`). The type tells you\nwhich variables a template can consume, not which ones it needs — there is no way to\nmark one required, and a template that references none types as `Record<string, never>`.\nReferencing a variable in the template body (rather than only in a locale string) is\nwhat gets it into the type at all.\n\nThat matters because a placeholder with nothing behind it renders as the **empty\nstring** — no error, no leftover `{{…}}`. A typo'd variable name, a missing `data` key\nand a value that isn't a string or number all produce the same silently blank output, so\nrender with sample data and read the result rather than trusting that it compiled.\n\n## Rendering\n\n```ts\nconst rendered = renderEmailTemplate({\n name: 'verify-email', // EmailTemplateName (autocompleted)\n locale: 'en', // optional, defaults to 'en'\n data: { verifyUrl: url }, // EmailTemplateVariables<'verify-email'>\n})\n// rendered: { name, locale, subject, html, text?, variables, hash }\n```\n\nIt is synchronous, and it throws on an unknown template name or an unknown locale —\nthose are the only two failure modes; everything else degrades to blank output.\n\n`hash` is a stable content hash (useful as an idempotency / dedupe key on outgoing mail).\nThe meta file also carries per-locale `htmlHash` / `subjectHash` / `textHash` if you need\nto tell which part changed.\n\n`{{locale}}` is in scope alongside `{{appName}}`, and placeholders are resolved by\nrepeated passes so a locale string containing `{{verifyUrl}}` expands. The loop stops\nafter 5 passes, which only becomes visible with placeholders nested more deeply than\nthat — a shape worth avoiding rather than working around.\n\n## Sending through an EmailService\n\n`@pikku/core/services` defines `EmailService.send(input)` where `input` is one of\n`SendTextEmailInput`, `SendHTMLEmailInput`, or `SendTemplateEmailInput`:\n\n```ts\nimport type { EmailService } from '@pikku/core/services'\n\nawait email.send({\n to: user.email,\n template: { name: 'verify-email', locale: user.locale, data: { verifyUrl } },\n})\n```\n\n`LocalEmailService` (dev/test) captures the payload as-is. To actually render templates\nbefore sending, wrap a delegate service: when `input.template` is present, call\n`renderEmailTemplate` and forward `subject` / `html` / `text` to the delegate (e.g. a\nResend/SES/SMTP service). This wrapper is project-owned because `renderEmailTemplate`\nis generated per project; wire it in `services.ts` and inject it into functions.\n\n```ts\nasync send(input: SendEmailInput) {\n if (!('template' in input) || !input.template) return this.delegate.send(input)\n const r = renderEmailTemplate(input.template as RenderEmailInput<EmailTemplateName>)\n return this.delegate.send({\n to: input.to, from: input.from, subject: r.subject, html: r.html,\n ...(r.text ? { text: r.text } : {}),\n })\n}\n```\n\n## Generated artifacts\n\n- `.pikku/email/pikku-emails.gen.ts` — `renderEmailTemplate`, `EmailTemplateName`,\n `EmailLocale`, `EmailTemplateVariables<T>`, inlined templates/locales/partials/theme.\n- `.pikku/email/pikku-emails-meta.gen.json` — per-template `variables`, `hasHtml/Subject/Text`,\n and per-locale content hashes. Both are regenerated; keep them out of hand edits and\n (typically) git-ignored.\n\n## Gotchas\n\n- New template not appearing → you added `.html` but forgot `.subject.txt` (subject is\n required), or didn't rerun `pikku emails generate`.\n- Variable typed `unknown`/missing → it's only in a locale string for a different template;\n reference it in this template to scope it in.\n- Editing a locale string changes that template's content hash — expected; the hash covers\n the strings the template uses.\n- `layout.html` must contain `{{content}}` or the body is dropped. It is matched by the\n partial name `layout`, so renaming the file opts every template out of the wrapper.\n- A blank spot where a value should be is an unresolved placeholder, not a render\n failure — check the key's spelling and that the value is a string or number (objects\n and arrays resolve to empty).\n", "pikku-fabric/SKILL.md": "---\nname: pikku-fabric\ndescription: 'Build and convert apps for the Pikku Fabric platform. Covers SQLite/libSQL database setup with Kysely, fabric project layout, deploy provider config, `fabric.config.json`, and the pikku-verify workflow. TRIGGER when: user is working on a Fabric-hosted Pikku project, converting an app to Fabric format, or asking about Fabric deployment, database, or project conventions. DO NOT TRIGGER when: user is working on a generic (non-Fabric) Pikku deployment — use pikku-deploy-cloudflare, pikku-deploy-fastify, etc. instead.'\ninstallGroups: [fabric]\n---\n\n# Pikku Fabric\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. **Run structural validation first.** Before any edit, run:\n ```bash\n pikku fabric validate --json\n ```\n This prints every missing file, misconfigured field, and dependency gap with a `fixHint`. Address all `error` findings before proceeding — they block deploy. Resolve `warn` findings before testing — they cause runtime failures. `info` findings are best-practice gaps that are safe to defer.\n2. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n3. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n4. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n5. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n6. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nFabric is a serverless deployment platform for Pikku apps. Every Fabric app runs on Cloudflare Workers with a SQLite database (via libSQL/Turso). This skill covers what's unique to Fabric. For general Pikku concepts, function authoring, HTTP wiring, and more, see `pikku-concepts`, `pikku-http`, `pikku-services`, etc.\n\n## Before you start\n\nAlways run project discovery first:\n\n```bash\nyarn pikku meta context --json\n```\n\nCall the `pikku-meta` tool before grepping or editing a Fabric app.\n\n- Use `section: \"context\"` for the project map: functions, wires, workflows, capabilities, and source files.\n- Use `section: \"clients\"` before frontend/RPC work.\n- Use `section: \"functions\"` to list function ids, then `section: \"function\", id: \"<functionId>\"` for one function.\n- Use `section: \"schemas\"` to list schema names. Only request full JSON Schema bodies with `schemas: [\"SchemaName\"]` for the specific schemas needed.\n\nDo not load every schema body by default; that wastes context and usually makes the model worse.\n\nFor database work:\n\n- Use `pikku-db` for the actual attached Fabric database state: tables, columns, foreign keys, and applied migrations.\n- Use `pikku-meta` `section: \"schemas\"` for code-level JSON Schema contracts, not database introspection.\n- Do not inspect database credentials or connect to the database directly; Fabric Control already exposes the safe introspection surface.\n\n## Database: SQLite via libSQL\n\nFabric apps use SQLite, accessed via Kysely with the libSQL HTTP adapter. NOT PostgreSQL, NOT D1.\n\n### Setup in `services.ts`\n\n```typescript\nimport { Kysely, CamelCasePlugin } from 'kysely'\nimport { LibsqlWebDialect } from '@pikku/kysely-sqlite'\nimport type { DB } from '#pikku/db/schema.gen.js'\n\nconst databaseUrl = await variables.get('DATABASE_URL')\nlet kysely: Kysely<DB>\nif (databaseUrl) {\n kysely = new Kysely<DB>({\n dialect: new LibsqlWebDialect({ url: databaseUrl }),\n plugins: [new CamelCasePlugin()],\n })\n} else if (existingServices?.kysely) {\n kysely = existingServices.kysely as Kysely<DB>\n} else {\n throw new Error('kysely not provided and DATABASE_URL is unset')\n}\n```\n\nFabric injects `DATABASE_URL` as a variable binding when the stage starts. In local dev, `pikku db migrate` uses a local `dev.db` SQLite file.\n\n### Migrations\n\nMigrations are plain `.sql` files at the **project root**, in a directory named\nfor the engine — `db/sqlite/` for SQLite/libSQL stages, `db/postgres/` for\nPostgres ones. Never `db/migrations/`, and never under `packages/functions/`:\nthe deploy pipeline stages `db/<engine>/*.sql` from the root and applies them\nafter upload, so a migration anywhere else is silently never run.\n\n```\ndb/sqlite/\n 0001-init.sql\n 0002-add-users.sql\n```\n\nNumbers must be consecutive and gap-free, and an applied migration is frozen —\ncorrect a mistake with a new forward migration, never by editing or renaming one\nthat has already run (the recorded hash will no longer match).\n\nRun migrations: `pikku db migrate`. It also regenerates `.pikku/db/schema.gen.ts`\n(Kysely types) and `.pikku/db/zod.gen.ts` — there is no separate types step.\n\n**NEVER hand-edit the generated schema** — write a migration and re-run.\n\n### Dev seed data\n\nAlongside the migrations sits `db/<engine>-dev-seed.sql` — `db/sqlite-dev-seed.sql`\nor `db/postgres-dev-seed.sql`. There is no seed command. `pikku db reset` is the\nonly thing that applies it: wipe, migrate, seed. `--no-seed` stops after the\nmigration, for working on an empty-state or onboarding flow the test data hides.\n\nBecause reset always arrives at a database it has just wiped, **the seed file is\nplain `INSERT`s** — no `INSERT OR IGNORE`, no `ON CONFLICT DO NOTHING`, no\n`IF NOT EXISTS`. Nothing applies it twice, so it never has to defend itself. If\nyou find yourself reaching for an idempotent form, that's a sign the data wants\nto be a migration instead.\n\nThis is **test data only**: enough rows that a fresh dev database isn't an empty\napp. It never reaches staging or production — reset refuses `NODE_ENV=production`\nand refuses a database outside the runtime directory. Anything a real environment\nneeds — accounts, role grants — is provisioning, not seeding, and belongs in\n`pikku persona sync` or a migration.\n\nA Better Auth app has a second constraint: the plugins you enable (`admin()`,\n`actor()`, …) each declare columns, and `pikku db migrate` refuses to run while\nthe applied schema is missing any of them. `pikku db generate` writes the\nmigration that closes the gap.\n\n### Column conventions\n\n- Use `SERIAL`/`INTEGER PRIMARY KEY AUTOINCREMENT` for IDs\n- Use `TEXT` for strings, `INTEGER` for booleans (0/1) and timestamps (Unix ms)\n- Use `CHECK` constraints sparingly — prefer app-level validation\n- Table and column names: snake_case in SQL, camelCase in TypeScript (via `CamelCasePlugin`)\n\n## Deploy Provider\n\n`pikku.config.json` (in the project root, not `packages/functions/`) **must** declare the Fabric deploy provider:\n\n```json\n{\n \"deploy\": {\n \"providers\": {\n \"cloudflare\": \"@pikkufabric/deploy-cloudflare\"\n }\n }\n}\n```\n\nWithout this, `pikku deploy plan --provider cloudflare` uses the OSS adapter which lacks Fabric's workflow service wiring.\n\nThe Fabric adapter automatically:\n\n- Injects `SQLiteKyselyWorkflowService` when `DATABASE_URL` is bound\n- Sets up the libSQL workflow queue\n- Wires `workflowQueues: true` for the scaffold\n\nNo manual workflow service setup is needed.\n\n## Project Layout\n\n```\npackages/functions/\n src/\n functions/ # Business logic — one pikkuFunc/workflow per file\n wirings/ # Transport bindings\n *.http.ts # wireHTTP / defineHTTPRoutes / wireHTTPRoutes\n *.channel.ts # wireChannel\n *.queue.ts # wireQueueWorker\n *.schedule.ts # wireScheduler\n *.mcp.ts # wireMCPResource / wireMCPPrompt (an MCP tool is just a function with `mcp: true`)\n *.cli.ts # wireCLI\n services.ts # pikkuServices factory (singleton)\n middleware.ts # Shared middleware\n permissions.ts # Shared permissions\n .pikku/\n db/schema.gen.ts # Kysely types, written by `pikku db migrate` — NEVER hand-edit\napps/app/ # Frontend(s)\ndb/sqlite/ # Plain .sql migrations, numbered, gap-free (project root)\ndb/sqlite-dev-seed.sql # Dev-only test data, applied by `pikku db reset`\npikku.config.json # Pikku + deploy config (project root)\npikkufabric.config.json # Fabric project link + frontends (project root)\n```\n\n## `pikkufabric.config.json`\n\nLinks the repo to a Fabric project and declares its frontends:\n\n```json\n{\n \"projectId\": \"my-project-id\",\n \"production\": {\n \"domain\": \"example.com\"\n },\n \"frontends\": {\n \"app\": {\n \"cwd\": \"apps/app\",\n \"primary\": true,\n \"deploy\": true,\n \"kind\": \"ssr\",\n \"dev\": {\n \"command\": [\"yarn\", \"dev\"],\n \"port\": 7105,\n \"healthPath\": \"/\"\n }\n }\n }\n}\n```\n\n- `projectId`: written by `pikku fabric init` / `link`. Templates ship the\n `__PROJECT_ID__` placeholder — that is _not_ a link, and the CLI treats it as\n unlinked.\n- `production.domain`: optional custom domain. Production always maps to `main`;\n without a domain it lives on the platform `*.pikkufabric.app` hostnames.\n- `frontends`: each entry declares a frontend app with its dev command and port\n\nSeveral CLI messages call this file `fabric.config.json` — `fabric init --force`,\n`fabric link --apiUrl`, and the `domains` commands' \"No fabric.config.json found\".\nThe file the CLI actually reads and writes is `pikkufabric.config.json`; don't\ncreate the shorter name to satisfy an error message.\n\n## RPC is the default transport\n\nIn Fabric apps, most features don't need HTTP wirings. Just write the function with `expose: true` — Pikku generates an RPC client and React Query hooks automatically.\n\n```typescript\nexport const listTasks = pikkuSessionlessFunc({\n expose: true,\n readonly: true,\n func: async ({ kysely }, {}) => {\n return { tasks: await kysely.selectFrom('tasks').selectAll().execute() }\n },\n})\n```\n\nAdd `wireHTTP` only when you need a specific REST shape (webhooks, third-party callers).\n\n### Transport rule\n\n- Always use RPC first.\n- If the function should be callable from the app or other generated clients, prefer `expose: true`.\n- Use `expose: true` for public/generated client access unless the user explicitly wants a private function.\n- Do not add HTTP routes unless the user explicitly asks for HTTP/REST, or the project settings explicitly require HTTP transport.\n- Every new or changed function must have a real description.\n- If function metadata would show `missing description`, the work is not finished yet.\n\n## Run it locally\n\nA Fabric app is two processes: the pikku API server (`:3000`) and the frontend\n(vite). The starter template's `bun run dev` starts **both** and takes the whole\nsession down if either dies — a frontend running against a dead API looks like an\napp bug and is the single most common way to waste an hour here.\n\n```bash\nbun run prebuild # pikku all — codegen must be current before the server boots\nbun run dev\n```\n\nThen open the app, sign up as a real user, and click through what you built.\n**HTTP 200 is not evidence.** These are client-rendered pages: the server returns\n200 with an empty shell, so a page whose component throws still looks fine to\ncurl. Either open it in a browser or drive it headlessly and assert on rendered\ntext.\n\nSecrets come from `process.env`, which the CLI populates from a `.env` in the\nworking directory. `BETTER_AUTH_SECRET` is required — without it the first\nsign-up fails with `Requested secret not found`, which names no key and points at\nno file. The starter template generates one on first `bun run dev`.\n\nIf you are running the two processes yourself rather than through the template's\nscript, run `pikku dev` from the **project root** (it resolves `srcDirectories`\nrelative to the config, so a nested cwd yields a doubled watch path and no hot\nreload).\n\n## Deploy\n\n```bash\npikku fabric login # opens a browser; needs a human, wait for it\npikku fabric init https://github.com/<owner>/<repo>\npikku fabric validate # must pass clean\npikku fabric deploy plan --production\npikku fabric deploy apply --production --auto-apply\n```\n\n`apply` confirms before deploying, and with no TTY to ask — CI, an agent shell —\nit refuses rather than hangs. `--auto-apply` supplies that confirmation; drop it\nonly when a human is at a real terminal.\n\n`init` adopts a **GitHub** repo, and adoption goes through the Pikku Fabric\nGitHub App — the app has to be installed on the account or org that owns the\nrepo, and if it is installed with \"selected repositories\" this one must be in\nthe selection. There is no CLI flag that works around a missing installation:\n`init` returns \"Connect the GitHub account '<owner>'\". Send the user to install\nit, or create the project in the console instead (which provisions a Fabric-hosted\ngit repo you push to) and write the returned `projectId` into\n`pikkufabric.config.json` yourself.\n\nDeploy refuses to run unless the target branch equals its upstream — the guard\ncompares `main` against `main@{upstream}`. So the remote you pushed to must be\nthe one the branch tracks; a stale `origin` left over from scaffolding blocks\nthe deploy with \"local HEAD … ≠ remote …\" even though your code is pushed.\n`git branch --set-upstream-to=<remote>/main main` before deploying.\n\n## Versioning\n\nFunctions with `expose: true` are versioned via `versions.pikku.json`. When you change a function's input or output schema, you must bump its version number — otherwise `pikku all` will report a breaking change and callers' generated clients become stale.\n\nThe `pikku-verify` tool catches this automatically.\n\n## After every code change\n\nAlways call the `pikku-verify` tool after modifying functions, wirings, or schemas. It runs:\n\n1. `pikku all` — regenerates all codegen, checks version compliance\n2. `tsc --noEmit` — validates TypeScript types\n\nThe output card shows whether any breaking changes were detected.\n\n## Hard rules\n\nThese apply in every Fabric app:\n\n- **No `process.env`** — use `variables.get('NAME')` and `secrets.getSecret('NAME')`. Declare with `defineVariable` / `defineSecret`.\n- **No `as any`** — fix types properly.\n- **No generic `Error`** — throw `NotFoundError`, `ConflictError`, `BadRequestError`, `UnauthorizedError` from `@pikku/core/errors`.\n- **No auth checks in function bodies** — use `permissions:` field on the function config with a `pikkuPermission` factory.\n- **No hand-editing `.pikku/db/schema.gen.ts`** — write a migration and re-run `pikku db migrate`.\n- **One runtime unit per file** — never define multiple functions/workflows in a single source file.\n- **Workflow steps don't need manual wiring** — `pikkuSessionlessFunc` step functions in `*.steps.ts` files are auto-discovered by codegen.\n\n## Converting an existing app to Fabric format\n\nStart by running the structural validator — it tells you exactly what is missing:\n\n```bash\npikku fabric validate --json\n```\n\nFix every `error` and `warn` in the output before continuing. Then:\n\n1. **Replace the database layer**: swap PostgreSQL/MySQL queries for Kysely + libSQL. Convert schema to SQLite-compatible SQL migrations in `db/sqlite/`.\n2. **Replace route handlers with pikkuFuncs**: extract business logic into `pikkuFunc`/`pikkuSessionlessFunc`, add `wireHTTP` or `expose: true` for transport.\n3. **Replace DI/IoC with pikkuServices**: move service construction to `createSingletonServices` in `services.ts`.\n4. **Replace `process.env` calls**: plain config becomes `defineVariable` + `variables.get()`, anything sensitive becomes `defineSecret` + `secrets.getSecret()`.\n5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles`.\n6. **Add `pikkufabric.config.json`** at project root with `projectId`, `production.domain`, and `frontends` (production is always `main`, so there is no `production.branch`).\n7. **Run `pikku all`** — verify codegen succeeds and there are no type errors.\n8. **Run `pikku fabric validate`** once more to confirm no structural issues remain.\n", "pikku-fabric-debug/SKILL.md": "---\nname: pikku-fabric-debug\ndescription: 'Debug a deployed Fabric stage from the CLI — read logs, find recent errors, follow a single request end-to-end by traceId, and check request/error/latency metrics. TRIGGER when: a deployed Fabric app is erroring, timing out, or behaving differently than local; the user asks \"why is prod failing\", \"check the logs\", \"what happened to this request\"; or a deploy succeeded but the app misbehaves. DO NOT TRIGGER when: the failure reproduces locally (debug it locally), the deploy itself failed (use pikku-fabric — that is a build/config problem, not a runtime one), or the project is not deployed to Fabric.'\ninstallGroups: [fabric]\n---\n\n# Debugging a deployed Fabric stage\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Reproduce locally first. If it fails locally too, debug it there — the\n deployed stage adds cost and latency to every iteration.\n2. Start from `errors`, not `logs`. Errors are already filtered and carry the\n traceId that unlocks the rest.\n3. Follow one trace end-to-end before forming a theory. A single failing request\n tells you more than a hundred unrelated log lines.\n4. Fix the source cause and redeploy. Never leave the diagnosis at \"it is flaky\".\n5. Confirm the fix against the same stage — recheck `errors` for the function.\n\nEvery command below requires a logged-in CLI and a linked project. Both fail\nwith the exact remediation if not:\n\n```\nNot logged in. Run `pikku fabric login` first.\nNo fabric project linked. Run `pikku fabric link` first.\n```\n\n## The loop\n\n**1 — What is broken?**\n\n```bash\npikku fabric errors -b main # branch defaults to main\npikku fabric errors -b main --function createOrder\n```\n\nPrints a `WHEN | FUNCTION | TRACE | MESSAGE` table. The message is **truncated\nto 100 characters** — treat it as a label, not the full error. The TRACE column\nis the input to the next step.\n\n**2 — What happened in that one request?**\n\n```bash\npikku fabric trace <traceId> -b main\npikku fabric trace <traceId> -b main --json\n```\n\n`--branch` is **required** here (no default). Each event prints as:\n\n```\n<timestamp> <scriptName> <wireType>:<wireId> <duration>ms — <error|message|outcome>\n```\n\nThis is the whole request across the stage — every unit it touched, in order,\nwith per-event durations. The last event before the failure is where to look.\n\n**3 — Is it one request or the whole stage?**\n\n```bash\npikku fabric metrics -b main # last 24h\npikku fabric metrics -b main --hours 2 --function createOrder\n```\n\n`--branch` is **required** here too; `--hours` defaults to 24.\n\nRows are `reqs= err= (rate%) avg= min= max=` per bucket. A single bad request\nwith a healthy error rate is a data problem; a climbing error rate is a\ndeployment or dependency problem. `--json` additionally returns a `wireTypes`\nbreakdown (requests per http/queue/scheduler/…) that the table output omits.\n\n**4 — Wider context around the failure**\n\n```bash\npikku fabric logs -b main\npikku fabric logs -b main --level warn\npikku fabric logs -b main -f # follow\n```\n\n`--branch` is **required** — `logs` throws `Specify --branch <branch-name>.`\nwithout it, even though the flag reads as optional.\n\n**5 — Is the running code the code you think it is?**\n\n```bash\npikku fabric status # active + in-flight deployment, per stage, with gitSha\n```\n\nCheck this *before* deep-diving. A stage still serving an older `gitSha`, or a\ndeploy stuck in flight, explains a whole class of \"my fix did nothing\".\n\n## Known gaps — do not misread these as bugs in your app\n\n- **`pikku fabric logs --since` and `--deployment` are accepted and ignored.**\n They are declared as options but the command never reads them, so\n `--since 15m` silently returns the same default window as no flag at all. Do\n not conclude \"nothing happened in the last 15 minutes\" from it. Narrow by\n `--level`, or by `--function` via `errors`, instead.\n- **`--follow` is a 2-second client-side poll, not a server stream** — despite\n its own help text reading \"Stream new logs (SSE)\". Server-side SSE is planned;\n the backend doesn't push natively today. It\n dedups against what it already printed, so it behaves like `tail -f`, but new\n entries can appear up to ~2s late and it holds the process open until killed.\n\n## What NOT to do\n\n- **Do not SSH anywhere or query the telemetry backend directly.** These\n commands are the supported surface; anything lower-level is Fabric-internal\n and will not exist for your project.\n- **Do not debug by redeploying with added `console.log`s.** Get the traceId,\n read the trace. A deploy cycle per hypothesis is the slow path.\n- **Do not read the truncated `errors` message as the full error.** Always\n confirm against `trace` before changing code.\n- **Do not treat an empty `errors` table as \"the app is fine\"** — a request that\n returns a wrong 200 logs nothing. Check `metrics` for the outcome mix.\n", "pikku-feature/SKILL.md": "---\nname: pikku-feature\ndescription: 'Drive create-a-feature work for a Pikku project: discover project context, work on a feature branch, implement + verify + commit, and ask the user to review via the diff. TRIGGER when: the user asks to \"create a feature\", \"build a todo app\", \"add X to my Pikku project\", \"wire up a new endpoint\", or anything that implies turning a natural-language request into Pikku functions/wirings/migrations. DO NOT TRIGGER when: the user asks for a one-off code edit in an existing function, or asks about Pikku concepts (use pikku-concepts).'\ninstallGroups: [core]\nallowed-tools: Bash(yarn pikku meta *), Bash(yarn pikku all *), Bash(yarn tsc), Bash(git status *), Bash(git diff *), Bash(git switch *), Bash(git checkout *), Bash(git checkout -b *), Bash(git add *), Bash(git commit *), Bash(git log *), Bash(git branch *)\nargument-hint: '<feature description>'\n---\n\n# Pikku Create-a-Feature\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nEnd-to-end flow: **discover → state intent → branch → implement → verify → commit → hand to reviewer**.\n\nThere is **no plan JSON**. The branch + diff IS the contract. The reviewer\nsees real, compiled, working code. Apply = merge. Reject = `git branch -D`.\n\n## Stage 1 — Discover\n\nRun **once** at the start of every feature request:\n\n```bash\nyarn pikku meta context --json\n```\n\nThis single call returns functions, wires, middleware, permissions, workflows,\n`capabilities` (which wire types are in use), and `layout` (where new files\nshould land).\n\nOnly fall back to targeted commands when you need full input/output JSON\nschemas (`yarn pikku meta functions get <id>`) or workflow steps\n(`yarn pikku meta workflows get <id>`).\n\n**Capability rule:** do not introduce new wires of a type whose\n`capabilities.<type>` is `false` unless the user explicitly asked for it.\n\n## Stage 2 — State intent in plain English (BEFORE writing code)\n\nBefore touching any files, give the user one paragraph stating exactly what\nyou'll do. This is the lightweight \"plan\" — it is chat, not JSON.\n\n> I'll add a `todos` table via a new migration in `sql/`, and two\n> `pikkuSessionlessFunc`s (`createTodo`, `listTodos` with\n> `readonly: true`) in `packages/functions/src/functions/`. Both\n> `expose: true`, so they'll be reachable via the auto-generated RPC\n> client and React Query hooks — no HTTP wiring needed. No new\n> dependencies. OK to proceed?\n\nWait for the user to confirm or redirect. They can ask for changes (\"use the\nexisting tasks table\" / \"make it a queue not http\") in normal chat — no\nschema, no JSON, no ceremony.\n\n**Non-interactive runs (auto mode, CI, batch jobs):** state intent in one\nparagraph and proceed without waiting. Surface course corrections promptly\nin the post-implementation report.\n\n## Stage 3 — Branch off\n\nAfter confirmation, ensure the working tree is clean and create a feature\nbranch off the current default branch (whatever `git branch --show-current`\nreturns at the start — `main`, `master`, `develop`, all fine):\n\n```bash\ngit status\ngit switch -c feature/<short-slug>\n```\n\nIf the working tree is dirty, **stop and ask** — never stash silently or\noverwrite uncommitted work.\n\n## Stage 4 — Implement\n\nWrite the code as a normal human contributor would. Use the project's\nexisting conventions (look at neighbour files in `srcDirectories[0]/functions/`\nand `.../wirings/` for style).\n\n### RPC is the default transport\n\n**Just write the function with `expose: true`** — that's enough to make it\ncallable. Pikku auto-generates an RPC client (and React Query hooks if the\nproject's `clientFiles.reactQueryFile` is set) from every exposed function.\nYou do **not** need an HTTP wiring for callers to reach the function.\n\nDefault flow for a feature:\n\n1. Write the function file with `expose: true` (and `readonly: true` for\n reads).\n2. Run `pikku all` — RPC map, fetch client, and React Query hooks are\n regenerated. Frontends call `useListTodos()` / `mutation.mutate(...)`\n without you wiring anything.\n\nAdd an HTTP wiring **only when** the feature genuinely needs a specific\nREST shape (third-party callers, webhooks, REST-conventional URLs). Most\nin-app features don't.\n\n### Hard rules that always apply\n\n- **`expose: true`** for any function called from a frontend or another\n service. Without it the RPC client won't generate hooks for it.\n- **`readonly: true` for queries.** Mark read functions as `readonly: true`\n on the function config. The runner uses this to enforce read-only sessions\n (a write func called under a readonly session is rejected). The RPC layer\n also uses it to pick `useQuery` (cacheable) vs `useMutation` for client\n hooks. Mutations leave `readonly` unset (or `false`).\n- **`kind` ⇔ `auth` coupling for HTTP wirings (when you have one).** If the\n function is `pikkuFunc` (session-aware), the HTTP wiring needs\n `auth: true`. `pikkuSessionlessFunc` ⇒ `auth: false`. Mismatching is a\n hard error (PKU573).\n- **HTTP method by intent (when you wire HTTP).** Reads → `GET`. Writes →\n `POST`/`PUT`/`PATCH`/`DELETE` per REST conventions.\n- **Workflows.** Prefer `pikkuWorkflowGraph` (DSL) over\n `pikkuWorkflowComplexFunc`. `mode: 'inline'` is sync; `'distributed'` is\n queue-dispatched.\n- **Auth checks belong on the function or wiring**, not in function bodies.\n Use the `permissions` field with a `pikkuPermission` factory.\n- **Throw typed errors** from `@pikku/core/errors` — `NotFoundError`,\n `ConflictError`, `BadRequestError`. Never bare `Error`.\n- **Migrations are inline SQL files** in the project's migrations dir\n (typically `sql/`). Use a numbered prefix matching existing files.\n- **Secrets and env-vars: NEVER `process.env`.** Declare them with\n `defineSecret` (sensitive) or `defineVariable` (non-sensitive) — both with a\n zod schema for type-safe access. Read variables with\n `services.variables.get('NAME')`. Secrets are **not available in functions** —\n read them in `services.ts` with `secrets.getSecret('NAME')` and pass the value\n into the service the function uses. See the **pikku-config** skill for the full\n pattern (including OAuth2 credentials). This applies even in `config.ts`.\n\n### Conventions to copy from neighbours\n\nSome patterns vary by project; **read a neighbour file before writing**:\n\n- **Function shape**: zod schemas as exported `const`s (`CreateTodoInput`,\n `CreateTodoOutput`) passed to `input`/`output` on the func config — vs\n generic-typed config. Schema name **must match codegen expectations** (the\n exported const name = the schema name in generated `.gen.json`).\n- **Imports**: usually `'#pikku'` for `pikkuFunc` / `pikkuSessionlessFunc`\n etc. Copy what neighbours do.\n- **Service usage**: e.g. `kysely`, `redis`. Look at how an existing function\n destructures services from its first arg. **Check `application-types.d.ts`**\n to see whether services like `kysely` are typed (`Kysely<DB>`) or untyped\n (`Kysely<any>`) — that drives whether you can lean on generated DB types\n or have to coerce manually.\n- **DB schema namespace**: many projects put tables under a `CREATE SCHEMA`\n (e.g. `app.todos`). Read the first migration in `sql/` to see the\n convention; reuse helper functions/triggers (e.g. `update_last_updated_at`)\n rather than redefining them.\n- **HTTP wiring style** (only relevant if you're adding one). Two common\n shapes — match what the project already uses:\n - Per-route `wireHTTP({ method, route, func, auth })`.\n - Single map: `const routes = defineHTTPRoutes({ auth: false, routes: {\nfooName: { method: 'post', route: '/foo', func: fooFunc } }}); wireHTTPRoutes(routes)`.\n\nFor shared wiring files (e.g. `todos.http.ts` holding both create and list):\ncreate the file with imports if it doesn't exist; **append** wire calls and\nadd missing imports if it does.\n\n## Stage 5 — Verify\n\nBoth must complete cleanly **for your changes** before committing:\n\n```bash\nyarn pikku all\n# Type-check the workspaces you touched:\ncd packages/functions && npx tsc --noEmit\n```\n\nNotes on running `tsc`:\n\n- A root-level `yarn tsc` may be a no-op in monorepos that don't define a\n `tsc` script in each workspace. Don't trust an exit-zero from the root if\n no actual checking happened — verify by running `npx tsc --noEmit` in the\n package(s) you touched.\n\n### What \"fails\" means\n\n**Trust the exit code, not the stderr noise.** `yarn pikku all` may print\nwarnings, `[PKUxxx]` messages, even `level: critical` log lines, while\nstill exiting `0` — those are pre-existing project state, not your\nproblem. Same for `meta context --json`: it streams logs to stderr that\nlook scary on a clean baseline. The exit code is the source of truth.\n\nIf a command exits non-zero, that's a real failure — fix or stop.\n\n### Baseline noise — only your errors matter\n\nMany real-world projects ship with pre-existing warnings or errors\n(legacy types, version drift, gen-layer messages). Those are not your\nproblem; do not \"fix\" them.\n\nTo distinguish your errors from baseline:\n\n1. **Before implementing** (Stage 4), capture the baseline:\n ```bash\n yarn pikku all 2>&1 | tee /tmp/pikku-before.log\n ```\n2. **After implementing**, compare:\n ```bash\n yarn pikku all 2>&1 | tee /tmp/pikku-after.log\n diff /tmp/pikku-before.log /tmp/pikku-after.log\n ```\n\nA clean diff means your changes introduced no new issues — even if the\nunderlying logs both show pre-existing warnings.\n\nIf something genuinely failed because of YOUR change, fix the actual issue.\n**Do not** mask errors with `as any`, `@ts-ignore`, or `--no-verify`. If\nyou're stuck, surface the failure to the user — don't hand them a broken\nbranch.\n\n## Stage 6 — Commit\n\n```bash\ngit add <the files you changed>\ngit commit -m \"feat: <short title>\"\n```\n\nStage the files you actually touched, by path. `git add -A` / `git add .` also\nsweeps up regenerated artifacts you didn't mean to commit and, where more than\none agent shares the checkout, another agent's in-progress work — which lands in\nyour branch and silently breaks theirs.\n\n## Stage 7 — Hand off\n\nTell the user the branch name and how to review. Two options:\n\n- **Local review:** open the pikku console — the changes view diffs the\n current branch against `main` with pikku-aware structure (added functions,\n new wires, migrations).\n- **PR review:** ask before pushing. Once they confirm, `git push -u origin\nfeature/<slug>` and surface the PR-create URL.\n\nDo not push without explicit confirmation. Do not merge.\n\n## Hard constraints\n\nThe skill's `allowed-tools` does **not** permit:\n\n- `yarn add` / `npm install` / dependency changes (ask the user first)\n- `yarn dbmigrate` (never run migrations against the real DB during planning)\n- `pikku deploy apply` (never deploy)\n- secret writes\n- network calls beyond what the implementation requires\n\nIf the feature genuinely needs any of these, **stop and ask** with a clear\nexplanation of why and what would change.\n\n## Output discipline\n\n- Stage 2 (intent statement) is plain English, one paragraph.\n- Between stages, give one-line updates: \"Discovered 30 functions, http+queue\n in use. Drafting intent...\" → \"Branch `feature/todos` created, implementing...\"\n → \"`pikku all` clean, `tsc` clean, committed. Review via console or run\n `git diff main`.\"\n- Don't narrate file-by-file. Only surface what's interesting (new patterns,\n judgment calls, things you suppressed).\n", "pikku-gateway-slack/SKILL.md": "---\nname: pikku-gateway-slack\ndescription: >-\n Use when integrating Slack with a Pikku app. Covers SlackGatewayAdapter, slash commands, OAuth\n flow, message handling, and signature verification. TRIGGER when: code uses SlackGatewayAdapter,\n parseSlashCommand, buildSlackInstallUrl, or user asks about Slack integration, Slack bots, or\n @pikku/gateway-slack. DO NOT TRIGGER when: user asks about general gateway/webhook patterns (use\n pikku-trigger).\n---\n\n# Pikku Gateway Slack\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/gateway-slack` provides a Slack Events API gateway adapter, slash command handling, OAuth installation flow, and message utilities.\n\n## Installation\n\n```bash\nyarn add @pikku/gateway-slack @slack/web-api\n```\n\n## API Reference\n\n### `SlackGatewayAdapter`\n\n```typescript\nimport { SlackGatewayAdapter } from '@pikku/gateway-slack'\n\nconst adapter = new SlackGatewayAdapter({\n signingSecret: string,\n tokenResolver: (teamId: string) => Promise<string | null>,\n})\n```\n\nBridges Slack Events API webhooks with Pikku's gateway system for processing Slack events as Pikku functions.\n\n**There is no `botToken` option.** One adapter serves every workspace, and the\nbot token is resolved per `team_id` through `tokenResolver` — normally a lookup\nagainst the row `exchangeSlackOAuthCode` wrote at install time. Returning `null`\nthrows for that event. `WebClient`s are cached per team, so call\n`invalidateClient(teamId)` after a token rotation.\n\n**Methods:**\n\n- `verifyWebhook(data, request?)` — asserts the signature, then answers the `url_verification` challenge. It **fails closed**: no request access, missing headers, a stale timestamp, or an HMAC mismatch all throw `UnauthorizedError` before parse or the handler runs\n- `parse(data)` — normalizes an `event_callback` into a `GatewayInboundMessage`, or returns `null` for anything to ignore\n- `createBoundSend(teamId, channelId, threadTs?)` — the real send path\n- `send(senderId, message)` — **a deliberate no-op.** The generic signature carries no channel context, and the gateway runner calls it for auto-send, so it swallows rather than throws. A reply written through it silently never reaches Slack\n- `getClientForTeam(teamId)` / `invalidateClient(teamId)` / `close()`\n\n`parse` returns `null` — meaning the event is dropped — for anything that isn't a\n`message` or `app_mention`, for bot messages (loop prevention), for any subtype\nother than `thread_broadcast`, and for events with no `user` or no `text`.\n`metadata` carries `{ teamId, channelId, threadTs, messageTs, eventType }`, with\n`threadTs` falling back to the message's own `ts` so replies always land\nin-thread.\n\n### `SlackGatewayHelper`\n\nWraps a parsed message plus the adapter and binds the channel/thread for you:\n\n```typescript\nconst slack = new SlackGatewayHelper(data, adapter)\nawait slack.sendText('Thinking…') // sends now\nreturn slack.reply('Here is the answer') // auto-sent by the runner\n```\n\nAlso: `send(message)`, `replyBlocks(blocks)`, and the `channelId` / `threadTs` /\n`teamId` getters.\n\n### Slash Commands\n\n```typescript\nimport { parseSlashCommand, respondToSlashCommand } from '@pikku/gateway-slack'\n\nconst command = parseSlashCommand(data)\n// { raw, subcommand, args, argsList, teamId, userId, channelId, triggerId, responseUrl }\nawait respondToSlashCommand(command.responseUrl, { text: 'Done!' })\n```\n\nThe parsed result is camelCase — reach for `command.responseUrl`, not\n`command.response_url`; the underlying snake_case payload is on `command.raw`.\n`text` is split on whitespace: the first word becomes `subcommand`, the rest\n`args`/`argsList`.\n\n`respondToSlashCommand` posts to the `response_url` and **ignores the result** —\na rejected response is invisible. Use it for the delayed reply when work exceeds\nSlack's 3-second acknowledgement window.\n\n### OAuth Flow\n\n```typescript\nimport {\n buildSlackInstallUrl,\n exchangeSlackOAuthCode,\n RECOMMENDED_BOT_SCOPES,\n} from '@pikku/gateway-slack'\n\nconst installUrl = buildSlackInstallUrl({\n clientId: config.slackClientId,\n scopes: RECOMMENDED_BOT_SCOPES,\n redirectUri: config.slackRedirectUri,\n})\n\nconst tokens = await exchangeSlackOAuthCode({\n clientId: config.slackClientId,\n clientSecret: config.slackClientSecret,\n code: oauthCode,\n redirectUri: config.slackRedirectUri,\n})\n```\n\n### Signature Verification\n\n```typescript\nimport { verifySlackSignature } from '@pikku/gateway-slack'\n\nverifySlackSignature(signingSecret, signature, timestamp, body): boolean\n```\n\n**Signature before timestamp** — the two middle arguments are both strings, so\nswapping them compiles and simply never verifies. `signature` is the raw\n`x-slack-signature` header (`v0=…`), `timestamp` is `x-slack-request-timestamp`\nin Unix seconds, and `body` must be the **raw** request body: any re-serialization\nchanges the HMAC.\n\nIt returns `false` rather than throwing, including for a timestamp more than 5\nminutes off (replay protection). The adapter already calls this for you — reach\nfor it directly only outside the gateway path, e.g. in a slash-command route.\n\n## Usage Patterns\n\n### Slack Bot Gateway\n\n```typescript\nimport { SlackGatewayAdapter } from '@pikku/gateway-slack'\n\nconst slackGateway = new SlackGatewayAdapter({\n signingSecret: config.slackSigningSecret,\n tokenResolver: async (teamId) => {\n const row = await kysely\n .selectFrom('slackInstall')\n .select('botToken')\n .where('teamId', '=', teamId)\n .executeTakeFirst()\n return row?.botToken ?? null\n },\n})\n```\n\n### Slash Command Handler\n\nSlack gives you 3 seconds to acknowledge, so anything slower answers immediately\nand posts the real result to `responseUrl` afterwards:\n\n```typescript\nconst handleSlashCommand = pikkuSessionlessFunc({\n title: 'Handle Slack Command',\n func: async ({ db }, data) => {\n const command = parseSlashCommand(data)\n await respondToSlashCommand(command.responseUrl, {\n text: `Processed: ${command.args}`,\n response_type: 'ephemeral',\n })\n },\n})\n```\n", "pikku-http/references/http-options.md": "# wireHTTP / defineHTTPRoutes / wireHTTPRoutes — full option reference\n\n## `wireHTTP(config)`\n\nWire a single function to an HTTP endpoint. Import from `#pikku`.\n\n| Option | Type | Notes |\n| --- | --- | --- |\n| `method` | `'get' \\| 'post' \\| 'put' \\| 'patch' \\| 'delete' \\| 'head' \\| 'options'` | HTTP verb |\n| `route` | `string` | e.g. `/books/:bookId` — `:params` become `data` fields |\n| `func` | `PikkuFunc` | The function to call |\n| `auth?` | `boolean` | Override default auth (`true` = require session) |\n| `tags?` | `string[]` | For grouping, middleware targeting |\n| `middleware?` | `PikkuMiddleware[]` | Per-route middleware |\n| `sse?` | `boolean` | Enable Server-Sent Events — **`method: 'get'` only** |\n| `query?` | `Array<keyof In>` | **`method: 'post'` only** — input fields also read from the query string |\n| `contentType?` | `'xml' \\| 'json'` | Response content type |\n| `timeout?` | `number` | Request timeout in ms |\n| `headers?` | `HTTPHeadersSchema` | Expected headers schema |\n\n`sse` and `query` are constrained by the config union rather than by a runtime\ncheck, so a `sse: true` on a `post` fails to typecheck rather than silently\nserving a normal response. OpenAPI metadata is not declared here — it is derived\nfrom the function's `description`/`summary` and its input/output schemas.\n\n## `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)`\n\nGroup routes with shared configuration. Groups are composable and nestable. Import from `#pikku`.\n\n```typescript\nconst routes = defineHTTPRoutes({\n basePath?: string, // Prepended to all route paths\n tags?: string[], // Applied to all routes in group\n auth?: boolean, // Default auth for all routes (overridable per-route)\n middleware?: PikkuMiddleware[],\n routes: {\n [key: string]: {\n method: string,\n route: string,\n func: PikkuFunc,\n auth?: boolean, // Override group auth\n middleware?: PikkuMiddleware[],\n }\n }\n})\n\nwireHTTPRoutes({\n basePath?: string, // Top-level prefix (e.g. '/api/v1')\n middleware?: PikkuMiddleware[],\n routes: {\n [key: string]: ReturnType<typeof defineHTTPRoutes>,\n }\n})\n```\n\nConfig cascading rules:\n\n- `basePath` — concatenates down the chain\n- `tags` — merge (union)\n- `auth` — child overrides parent\n", "pikku-http/SKILL.md": "---\nname: pikku-http\ndescription: >-\n Use when adding HTTP routes, REST APIs, web endpoints, or SSE streams to a Pikku app. Covers\n wireHTTP, defineHTTPRoutes, route groups, auth, middleware, SSE, and generated\n fetch client. TRIGGER when: code uses wireHTTP/defineHTTPRoutes/wireHTTPRoutes, user asks about\n REST endpoints, API routes, SSE, or the generated fetch client. DO NOT TRIGGER when: user asks\n about WebSocket (use pikku-websocket), queue workers (use pikku-queue), or deployment (use\n pikku-deploy-*).\ninstallGroups: [core]\n---\n\n# Pikku HTTP Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to HTTP endpoints. Supports single routes, composable route groups, auth, middleware, SSE, and auto-generated type-safe clients. (Authorization lives on the function, not the wiring — see `pikku-permissions`.)\n\n## Before You Start\n\nRun these commands to understand the current project:\n\n```bash\npikku info functions --verbose # See existing functions, their types, tags, middleware\npikku info tags --verbose # Understand project organization and naming conventions\npikku info middleware --verbose # See what middleware is already applied\n```\n\nFollow existing patterns you find (naming, tag usage, file organization). See `pikku-concepts` for the core mental model.\n\n## API Reference\n\nAll three come from `#pikku` (the generated `.pikku/pikku-types.gen.js`), which\nbinds them to your project's service, session and middleware types. The\n`@pikku/core/http` versions are the unbound generics — they compile, but you\nlose the typing that makes the wiring worth having.\n\n- `wireHTTP(config)` — wire one function to one endpoint.\n- `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)` — group routes with shared config; composable/nestable.\n\nFunction input/output types come from the function's own `input:`/`output:` zod schemas — never declared in the wiring. Route `:params`, query params, and body are merged into the function's `data` arg (see Data Flow).\n\nConfig cascading across groups: `basePath` concatenates down the chain, `tags` merge (union), `auth` child overrides parent.\n\nFor the full option tables (every `wireHTTP` field, the `defineHTTPRoutes`/`wireHTTPRoutes` config shape), read `references/http-options.md`.\n\n### `addHTTPMiddleware(pattern, middlewares)`\n\n```typescript\naddHTTPMiddleware('*', [authBearer()]) // All routes\naddHTTPMiddleware('/api/*', [rateLimit()]) // Pattern match\n```\n\n> HTTP-route-level permissions (`addHTTPPermission`, a `permissions` field on the wiring) were removed in #972. Declare authorization on the function definition (`pikkuFunc({ permissions })`, see `pikku-permissions`), or app-wide via `addGlobalPermission`. Tags/patterns are for *middleware* only now.\n\n## Data Flow\n\nPikku merges route params, query params, and request body into a single `data` object:\n\n```typescript\n// POST /books/42?format=pdf with body { title: \"New Title\" }\nwireHTTP({ method: 'post', route: '/books/:bookId', func: updateBook })\n// → updateBook receives: { bookId: \"42\", format: \"pdf\", title: \"New Title\" }\n```\n\n## Usage Patterns\n\n### Single Route\n\n```typescript\nwireHTTP({\n method: 'get',\n route: '/books/:bookId',\n func: getBook,\n})\n```\n\n### Route Groups (Recommended for CRUD)\n\n```typescript\nconst booksRoutes = defineHTTPRoutes({\n tags: ['books'],\n routes: {\n list: { method: 'get', route: '/books', func: listBooks, auth: false }, // per-route override\n get: { method: 'get', route: '/books/:bookId', func: getBook },\n create: { method: 'post', route: '/books', func: createBook },\n delete: { method: 'delete', route: '/books/:bookId', func: deleteBook },\n },\n})\n\nconst todosRoutes = defineHTTPRoutes({\n auth: false, // group-level default, overridable per-route\n tags: ['todos'],\n routes: {\n list: { method: 'get', route: '/todos', func: listTodos },\n },\n})\n\nwireHTTPRoutes({\n basePath: '/api/v1',\n middleware: [cors()],\n routes: { books: booksRoutes, todos: todosRoutes },\n})\n// Results in: GET /api/v1/books, POST /api/v1/books, GET /api/v1/todos, etc.\n```\n\n### Auth\n\n```typescript\n// Public route (no auth)\nwireHTTP({ method: 'get', route: '/books', func: listBooks, auth: false })\n\n// Authenticated route (default when a global auth middleware is set)\nwireHTTP({ method: 'delete', route: '/books/:bookId', func: deleteBook })\n```\n\nAuthorization is not a wiring concern — declare it on the function via `permissions` (see `pikku-permissions`), or app-wide via `addGlobalPermission`.\n\n### Middleware\n\n```typescript\nimport { cors, authBearer } from '@pikku/core/middleware'\n\n// Global middleware\naddHTTPMiddleware('*', [\n cors({ origin: 'https://app.example.com', credentials: true }),\n authBearer(),\n])\n\n// Scoped middleware\naddHTTPMiddleware('/api/*', [rateLimit({ maxRequests: 100, windowMs: 60_000 })])\n\n// Per-route middleware\nwireHTTP({\n method: 'delete',\n route: '/books/:bookId',\n func: deleteBook,\n middleware: [auditLog],\n})\n```\n\n### SSE (Server-Sent Events)\n\n`sse: true` is only accepted on `method: 'get'` — the wiring union offers it on\nno other verb.\n\n```typescript\nwireHTTP({\n method: 'get',\n route: '/todos',\n func: getTodos,\n sse: true,\n})\n\nconst getTodos = pikkuFunc({\n title: 'Get Todos',\n func: async ({ db }, {}, { channel }) => {\n const todos = await db.getTodos()\n\n if (channel) {\n for (const todo of todos) {\n channel.send({ todo })\n await sleep(100)\n }\n return\n }\n\n return { todos }\n },\n})\n```\n\n`channel` is on the **wire** — the func's third argument — not on services, and\nit is optional because the same function can be reached over plain HTTP or RPC,\nwhere there is no stream to send on. The `if (channel)` guard is what lets one\nfunction serve both; the return value is the non-streaming answer.\n\n### Generated Fetch Client\n\nAfter `npx pikku all`, a type-safe client is generated:\n\n```typescript\nimport { pikkuFetch } from '#pikku/pikku-fetch.gen.js'\n\npikkuFetch.setServerUrl('http://localhost:4002')\n\nconst books = await pikkuFetch.get('/api/v1/books', {})\nconst book = await pikkuFetch.get('/api/v1/books/:bookId', { bookId: '42' })\nconst created = await pikkuFetch.post('/api/v1/books', {\n title: 'The Pikku Guide',\n author: 'You',\n})\n\npikkuFetch.setAuthorizationJWT(token)\nconst deleted = await pikkuFetch.delete('/api/v1/books/:bookId', {\n bookId: created.bookId,\n})\n```\n\n## Complete Example\n\nFunctions live in their own files (one per file) and supply behavior + `permissions`; the wiring file imports them and wires routes. Sessionless funcs need no session; `pikkuFunc` does.\n\n```typescript\n// functions/books.functions.ts\nimport { pikkuFunc, pikkuSessionlessFunc } from '#pikku'\n\nexport const listBooks = pikkuSessionlessFunc({\n title: 'List Books',\n func: async ({ db }, { limit }) => ({ books: await db.listBooks(limit) }),\n})\n\nexport const getBook = pikkuFunc({\n title: 'Get Book',\n description: 'Retrieve a book by ID',\n func: async ({ db }, { bookId }) => await db.getBook(bookId),\n permissions: { user: isAuthenticated },\n})\n\n// wirings/books.http.ts — same defineHTTPRoutes/wireHTTPRoutes shape as the Route Groups example above\nimport { addHTTPMiddleware } from '#pikku'\nimport { cors, authBearer } from '@pikku/core/middleware'\n\naddHTTPMiddleware('*', [cors(), authBearer()])\n```\n", "pikku-i18n/SKILL.md": "---\nname: pikku-i18n\ndescription: 'Wire i18n into a Pikku frontend with Paraglide JS (inlang). English by default, every user-facing string is a typed message function (`m.some__key()`) compiled from `messages/<locale>.json`, and additional languages are served under `/fr` `/de` URL prefixes. TRIGGER when: scaffolding or editing a frontend and writing user-facing text, adding a second language, or asked to \"make this translatable / use tokens / add i18n\". DO NOT TRIGGER for backend functions, error messages thrown from functions, or log output.'\ninstallGroups: [core]\n---\n\n# Pikku i18n (Paraglide JS)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Every user-facing string in a frontend is a message. Never hardcode display text — add a key to `messages/en.json` and render `m.the__key()`. This holds even when the app ships only English; the messages are the seam a second language slots into later.\n2. One `messages/<locale>.json` per language at the app root (NOT under `src/`), declared in `project.inlang/settings.json`. English (`en`) is `baseLocale` and the only locale until someone adds another.\n3. Messages compile to typed ESM functions in `src/paraglide/` (generated, self-gitignored — never edit or commit it). The Vite plugin compiles during `dev`/`build` with HMR on message edits; run the CLI compile only when you need `tsc` before Vite has ever run.\n4. Validate with the app's own `tsc` then its `build`. The deploy pipeline compiles Paraglide and runs each frontend's `tsc` before building it — an i18n mistake blocks the deploy.\n\n## The moving parts (starter-template layout)\n\n- `messages/en.json` — flat keys, `{param}` interpolation, inlang message-format:\n ```json\n {\n \"$schema\": \"https://inlang.com/schema/inlang-message-format\",\n \"auth__login__title\": \"Sign in\",\n \"auth__login__description\": \"Welcome back to {name}.\"\n }\n ```\n Key convention: lower snake_case, `__` (double underscore) between namespace segments, `_` within a segment — `auth__login__title`, `common__email_placeholder`.\n- `project.inlang/settings.json` — `baseLocale`, `locales`, the `@inlang/plugin-message-format` module, `pathPattern: \"./messages/{locale}.json\"`.\n- `vite.config.ts` — `paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' })` from `@inlang/paraglide-js` (devDependency), FIRST in the plugins array.\n- `src/paraglide/` — compiled output (`messages.js`, `runtime.js`, per-locale `messages/*.js`). Generated; it writes its own `.gitignore`.\n- `src/i18n/config.ts` — locale plumbing, and the ONLY hand-written i18n module: `supportedLocales`/`defaultLocale` (re-exported from `../paraglide/runtime.js`), `detectLocale`, `localeDir` (RTL for ar/he/fa/ur), a reactive locale store (`overwriteGetLocale` bridged to `useSyncExternalStore`), `setActiveLocale`, `useLocale()`. This is not a wrapper over messages — Paraglide's `getLocale()` is a module global with no React reactivity, and this bridges it. Wire `overwriteGetLocale` or `m.*()` will resolve a different locale than the app thinks is active.\n- `tsconfig.json` — `\"allowJs\": true, \"checkJs\": false` so `tsc` can consume Paraglide's JSDoc-typed JS output.\n\n## Using messages in components\n\n```tsx\nimport { m } from '../paraglide/messages.js'\nimport { useLocale } from '@/i18n/config'\n\nfunction LoginPage() {\n useLocale() // subscribe: re-render m.*() when the locale switches\n return (\n <>\n <Title>{m.auth__login__title()}</Title>\n <Text>{m.auth__login__description({ name: m.app__name() })}</Text>\n </>\n )\n}\n```\n\n- Params: `{name}` in the JSON → `m.auth__login__description({ name })`. Params are typed per message.\n- Any component that renders `m.*()` calls `useLocale()` (bare call is enough); it also returns `{ locale, dir, setLocale }` for switchers.\n- Non-component helpers (formatters, status maps) call `m.some__key()` directly — the functions are plain ESM, no hook needed; the render-time subscription lives in the component that displays the result.\n- Locale switching: the root route persists to localStorage, sets `<html lang dir>` (`localeDir`), and calls `setActiveLocale` — in-SPA re-render, no page reload. Mirror `routes/__root.tsx` in the starter template.\n\n## Keys only known at runtime (enum labels, status maps)\n\nA DB value picking a label is the one case a generated message can't express.\nParaglide's README (§ \"What about dynamic or CMS-driven keys?\") is explicit: use\nan **explicit mapping from value to message function**. Key it on the enum type,\nnever `string`:\n\n```ts\nimport { m } from '../paraglide/messages.js'\n\nconst DOCUMENT_STATUS_LABEL: Record<DocumentStatus, () => string> = {\n completed: m.enum__document_status__completed,\n in_progress: m.enum__document_status__in_progress,\n required: m.enum__document_status__required,\n}\n\n// call site — no fallback, because there is no missing case\nDOCUMENT_STATUS_LABEL[status]()\n```\n\n`Record<DocumentStatus, …>` is exhaustive: add a value to the enum without a\nlabel and the build fails. That is the entire point.\n\n**Don't write these maps by hand.** `@pikku/paraglide` generates them from the\n`enum__<group>__<member>` keys in the catalog and types each one against the DB\nenum it mirrors, so a migration adding a status is a compile error rather than a\nmap someone forgot. Use the namespace above (singular `enum`, `__` between\nsegments) so the generator picks the group up, and read `pikku-paraglide` before\nadding one.\n\nDo NOT write `Record<string, () => string>` with a `?? status` fallback, and do\nNOT index the namespace with a computed key (`m[\\`enums__${name}__${value}\\`]`).\nBoth compile, both render the raw identifier to users when a label is missing,\nand both reintroduce exactly the silent-fallback failure Paraglide exists to\neliminate. If you find yourself writing a `resolveDynamicKey(key: string)`\nhelper, stop — that helper IS the bug.\n\n## Type safety — and why deploys block on i18n\n\nA message IS a function: a typo'd or deleted key (`m.auth__login__titel()`) is a missing export — a **TypeScript error**, not a silent runtime fallback string. Params are typed too. The deploy pipeline compiles Paraglide then runs each frontend's `tsc` (`\"tsc\": \"tsc --noEmit\"` script — keep it in every frontend's `package.json`) **before** building; a type error aborts the deploy. `vite build` does not type-check on its own, so this gate is the only thing standing between a broken message and production.\n\nThe gate catches _invalid_ messages but not _inlined_ strings. The `@pikku/mantine` `I18nNode` prop typing catches those: a raw string literal fails to compile on a gated prop, because `I18nString` is a branded type a bare `string` can't satisfy. Between the two, `tsc` is the whole safety net — there is no runtime fallback to inspect, by design.\n\n## Compile step\n\n- **Dev/build:** the Vite plugin compiles automatically; editing `messages/*.json` under a running dev server recompiles + HMRs.\n- **Standalone `tsc` before Vite has run** (fresh clone, CI):\n ```sh\n npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/paraglide\n ```\n This is exactly what the deploy CI does before the per-app `tsc`.\n\n## Adding a second language\n\n1. `messages/fr.json` mirroring `en.json`'s keys (translate the values, keep `{param}` names identical).\n2. Add `\"fr\"` to `locales` in `project.inlang/settings.json`.\n3. Recompile (restart/`vite dev` or the CLI compile). A locale file missing keys falls back to the base locale per message.\n4. Content is reachable via the `/<lang>` URL prefix (`detectLocale` already resolves it); the base locale needs no prefix. Expose the switcher via `useLocale().setLocale`.\n\n## i18n debug mode (find inlined strings)\n\n`tsc` catches invalid messages, and the `@pikku/mantine` gate catches raw strings on gated props — but neither sees a hardcoded string in plain JSX, an `aria-label`, `alt`, `document.title`, or anything passed to a non-Mantine component. Debug mode covers that gap: render every message as block glyphs (`█`), and whatever is still readable never went through a message.\n\n**Build it as a generated locale, never as a runtime wrapper.** Masked text is text, and rendering different text per locale is what Paraglide already does:\n\n1. A script generates `messages/zz.json` from `en.json`, replacing `\\S` with `█` while leaving `{placeholders}` intact (they are message inputs — mangling them changes the compiled signature). Run it before `paraglide-js compile`; gitignore the output.\n2. Add `\"zz\"` to `locales` in `project.inlang/settings.json`.\n3. Switch to it in the locale bridge:\n ```ts\n overwriteGetLocale(() => (isI18nDebug() ? 'zz' : activeLocale))\n ```\n\nKeep `zz` out of the app's own `supportedLocales` — that drives URL prefixes, hreflang and any backend `locale` param, none of which should see it.\n\nGenerate the catalogue in dev only. With `messages/zz.json` absent, Paraglide compiles `zz` to an alias of the base locale (`const zz_x = en_x` — one line per message, no duplicated strings), so a production bundle carries the locale at effectively zero cost.\n\nBoth the generator and the store bridge are being upstreamed (pikkujs/pikku#1036, #1035).\n\nThe wrapper alternative — a module that walks the namespace and pipes each message through a `mask()` — is what this replaces. It defeats tree-shaking (touching every export), adds a check on every call, and forces every component to import `m` from the wrapper instead of Paraglide.\n\n## What NOT to do\n\n- Don't hardcode display strings \"just for now\" — the message is the work.\n- Don't edit or commit anything under `src/paraglide/` — it's regenerated; change `messages/*.json` instead.\n- **Don't wrap `m`.** No re-export module, no branding layer, no resolver. Components import `m` from `../paraglide/messages.js` and call it. `@pikku/react`'s `I18nString` is declared as `string & { readonly __brand: 'LocalizedString' }` — deliberately identical to Paraglide's own `LocalizedString` — so `m.some__key()` satisfies the `@pikku/mantine` `I18nNode` gate natively. A wrapper adds nothing and costs per-message tree-shaking.\n\n `packages/console` is the one place in this repo that still wraps it, in `src/i18n/messages.ts`, to keep the debug mask (`█`) it carried over from i18next. That wrapper is a leftover, not a pattern — the generated-locale approach above is how a new app gets the same masking without touching every export. Don't copy it.\n\n The `mKey`/`mList` runtime resolvers that used to live beside it are **gone**, and must not come back. `mList` resolved indexed `prefix.0` keys that no longer exist; `mKey` took a computed string, which is exactly the type safety Paraglide exists to provide. Where a key really is dynamic, map the discriminant to a message *function* and call it — the map is type-checked, a string is not.\n- Don't re-resolve messages by string key or re-implement `{param}` interpolation. A key-string resolver turns a missing key back into silent runtime text, surrendering the type safety that is the entire reason to use Paraglide.\n- Don't reach for i18next/react-i18next or a runtime-fetch translation loader — Paraglide's compiled functions are the whole delivery mechanism.\n- Don't tokenize backend error messages or logs here — those are not frontend display strings.\n", "pikku-info/SKILL.md": "---\nname: pikku-info\ndescription: >-\n Discover what exists in a Pikku project — functions (with their transport, middleware and\n permissions), tags, middleware and permission definitions. Use when you need to understand the\n project structure, find existing functions, or check what middleware and permissions are\n defined. TRIGGER when: user asks \"what functions exist?\", \"show me the project structure\", \"list\n routes/middleware/permissions\", or needs to understand an existing Pikku codebase. DO NOT\n TRIGGER when: user is writing new code (use the specific wiring skill) or asking about Pikku\n concepts (use pikku-concepts).\ninstallGroups: [core]\nallowed-tools: Bash(yarn pikku info *)\nargument-hint: '[functions|tags|middleware|permissions] [--verbose] [--limit N]'\n---\n\n# Pikku Project Discovery\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nUse the `pikku info` CLI commands to inspect this Pikku project. Run the commands below and present the results to the user in a clear summary.\n\nThere are exactly four subcommands — `functions`, `tags`, `middleware`,\n`permissions`. Routes, channels, schedulers and queues are not separate\nsubcommands; they show up as the *transport* column of `info functions --verbose`.\n\n## Available Commands\n\n`--silent` suppresses the banner and the inspector's diagnostics, which is what\nyou want when parsing the table. It is read by the CLI but not declared as an\noption, so every run also prints `Warning: Unknown option: --silent (ignored)` —\nthe warning is wrong, the flag works. Ignore that one line.\n\nFor anything you intend to parse rather than read, prefer `--json` (alias\n`-j`, or `--output json`), which emits NDJSON instead of a formatted table.\n\n### Functions\n\nList all registered pikku functions:\n\n```bash\nyarn pikku info functions --silent\n```\n\nFor full details including transport type (http/channel/scheduler/queue/workflow/mcp/cli/trigger), middleware, permissions, and source file:\n\n```bash\nyarn pikku info functions --verbose --silent\n```\n\n### Tags\n\nList all tags with counts of associated functions and middleware:\n\n```bash\nyarn pikku info tags --silent\n```\n\nFor full names instead of counts:\n\n```bash\nyarn pikku info tags --verbose --silent\n```\n\n### Middleware\n\nList all middleware definitions:\n\n```bash\nyarn pikku info middleware --silent\n```\n\nFor full details including source file, required services, and description:\n\n```bash\nyarn pikku info middleware --verbose --silent\n```\n\n### Permissions\n\nList all permission definitions:\n\n```bash\nyarn pikku info permissions --silent\n```\n\nFor full details including source file, required services, and description:\n\n```bash\nyarn pikku info permissions --verbose --silent\n```\n\n## Instructions\n\n1. If the user specifies a subcommand (e.g., `/pikku-info functions`), run only that command.\n2. If no subcommand is specified, run all four commands to give a complete project overview.\n3. Always use `--silent` to suppress the Pikku banner and inspector logs, and disregard the spurious \"Unknown option\" warning it prints.\n4. Use `--verbose` when the user asks for details, file paths, or \"more info\". On `tags` it swaps counts for names; elsewhere it adds columns.\n5. Use `--limit N` to control output size (default is 50 rows) — the footer tells you how many were withheld.\n6. After running the commands, summarize the findings concisely:\n - Total count of functions, tags, middleware, and permissions\n - Notable patterns (e.g., which transport types are in use, which tags group the most functions)\n - Any functions without tags or transport types (potential issues)\n", "pikku-jose/SKILL.md": "---\nname: pikku-jose\ndescription: >-\n Use when setting up JWT authentication with the jose library in a Pikku app. Covers\n JoseJWTService constructor, secret rotation, token encoding/decoding/verification. TRIGGER when:\n code uses JoseJWTService, user asks about JWT setup, token signing, token verification, or\n @pikku/jose. DO NOT TRIGGER when: user asks about session middleware (use pikku-security) or\n general service setup (use pikku-services).\n---\n\n# Pikku Jose (JWT Service)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/jose` provides JWT signing, verification, and decoding using the [jose](https://github.com/panva/jose) library. Implements the `JWTService` interface from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/jose\n```\n\n## API Reference\n\n### `JoseJWTService`\n\n```typescript\nimport { JoseJWTService } from '@pikku/jose'\n\nconst jwt = new JoseJWTService(\n getSecrets: () => Promise<Array<{ id: string; value: string }>>,\n logger?: Logger\n)\n\nawait jwt.init()\n```\n\n**Constructor Parameters:**\n\n- `getSecrets` — Async function returning an array of `{ id, value }` key pairs. The **first** entry signs; every entry can verify.\n- `logger` — Optional logger instance.\n\n**Methods:**\n\n- `init(): Promise<void>` — Fetch and cache secrets. Call at startup.\n- `encode<T>(expiresIn: RelativeTimeInput, payload: T): Promise<string>` — Create a signed JWT, stamping the signing key's `id` as the token's `kid` header.\n- `decode<T>(token: string): Promise<T>` — **Verifies** the signature and expiry, then returns the payload.\n- `verify(token: string): Promise<void>` — The same check, discarding the payload.\n\n`decode` is not an unchecked read: both methods run `jose.jwtVerify` and both\nthrow on a bad signature or an expired token. There is no way to inspect an\nuntrusted payload through this service — reach for `jose.decodeJwt` directly if\nyou genuinely need that, and treat the result as unauthenticated input.\n\nTokens are signed **HS256** with a symmetric secret. The algorithm is fixed and\npinned on verification, so a token arriving with any other `alg` is rejected —\nbut it also means this service has no asymmetric (RS256/ES256) mode.\n\n`init()` is not strictly required: `encode` calls it lazily on first use. Call it\nat startup anyway so a missing or unreachable secret fails at boot rather than\non the first request that needs a token.\n\n## Usage Patterns\n\n### Basic Setup\n\n```typescript\nimport { JoseJWTService } from '@pikku/jose'\n\nconst jwt = new JoseJWTService(\n async () => [{ id: 'key-1', value: await secrets.getSecret('JWT_SECRET') }],\n logger\n)\nawait jwt.init()\n```\n\nA signing key is a secret, so it comes from the secrets service rather than\n`process.env` — and because `getSecrets` is a function called on demand, reading\nit there (not once at construction) is what makes the re-init-on-unknown-kid path\nabove actually see a rotated key. See `pikku-config`.\n\n### Secret Rotation\n\nSupply multiple keys. The first signs; the rest stay available for verification:\n\n```typescript\nconst jwt = new JoseJWTService(async () => [\n { id: 'key-2', value: NEW_SECRET }, // signs with this\n { id: 'key-1', value: OLD_SECRET }, // still verifies tokens signed with this\n])\n```\n\nVerification resolves the key by the token's `kid` header rather than trying each\nsecret in turn — which is why `encode` stamps the signing key's `id` there, and\nwhy the ids must stay stable across a rotation. Keep an id in the list for as\nlong as tokens bearing it can still be in flight.\n\nWhen a `kid` isn't in the cache, the service re-runs `getSecrets()` once before\ngiving up with `Missing secret for id: <kid>`. That is what lets a running server\npick up a newly added key without a restart, provided `getSecrets` reads from\nsomething live (a secret store) rather than a value captured at boot. A token\nwith no `kid` at all falls back to the current signing key.\n\n### With Pikku Services\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const jwt = new JoseJWTService(\n async () => [{ id: 'my-key', value: config.jwtSecret }],\n logger\n )\n await jwt.init()\n return { config, logger, jwt }\n})\n```\n\n### Encoding & Verifying Tokens\n\n```typescript\nconst token = await jwt.encode('1h', { userId: 'abc', role: 'admin' })\n\nawait jwt.verify(token) // throws if invalid/expired\n\nconst payload = await jwt.decode<{ userId: string; role: string }>(token)\n```\n", "pikku-knowledge/SKILL.md": "---\nname: pikku-knowledge\ndescription: >-\n Use when writing, reading, reorganising or validating a project's knowledge/ directory — the\n notes that say what the app is, in the language its users use. Covers the Open Knowledge Format\n note (path-as-identity markdown, YAML frontmatter, only `type` required), the sections of the\n app-project profile (slices, entities, decisions, questions, wishlist) and the one question each\n answers, slice status/entities/gherkin rules, the `resource:` URI scheme tying a note to the\n code it is about, the shapes that are NOT a knowledge base, and the `pikku knowledge\n validate|index` commands. TRIGGER when: user asks to write down a decision, requirement, entity\n or open question; asks what the app does or is; asks about knowledge/, notes, slices,\n an index.md, or a diagram, callout or decision block; or hands over a product\n brief to record. DO NOT TRIGGER when: user asks what\n functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a\n note), or to write a scenario test (use pikku-scenario).\ninstallGroups: [core]\n---\n\n# Pikku Knowledge\n\nThe knowledge base is `knowledge/` at the repo root: markdown notes about **what the app is**, written for whoever picks the project up next — human or agent.\n\nNot to be confused with `.knowledge/` — the dot-prefixed JSON blueprint that `pikku-software-archaeology` extracts from a legacy repo. Different directory, different format, different purpose.\n\n## Agent Operating Procedure\n\n1. **Read `knowledge/index.md` first**, then the section index for whatever you are about to touch. It is the cheapest way to learn what the app already claims about itself.\n2. Before writing a note, ask whether `pikku meta` already answers it. If it does, do not write the note — see _What never goes in a note_.\n3. Write the note in the section that answers its question. Create the section's `index.md` in the same turn you create the section.\n4. Add a `resource:` only if you can name a real id. A wrong one is worse than none.\n5. Run `pikku knowledge validate`. Fix what it reports.\n6. Run `pikku knowledge index` so each section lists what is actually in it.\n\n## The governing rule\n\n**Record only what pikku cannot tell you.**\n\nPikku already knows every function, route, schema, table, column, queue, cron, channel and permission — `pikku meta` prints them, and the generated meta is the truth. A note that lists tables or routes is a copy that starts drifting the moment somebody edits the code, and it drifts _while looking authoritative_, which is worse than silence.\n\nWhat a note is for is the part no generator can derive: what a thing means, why a rule was chosen, what it rules out, who asked for it, and what is still unanswered.\n\n## The note\n\nA note is a markdown file whose **path is its identity** — moving it renames it. It carries YAML frontmatter and a body:\n\n```markdown\n---\ntype: decision\ntitle: Revocation ends a grant\ndescription: A revoked grant stops working immediately, everywhere.\nresource: func:revokeGrant, table:grant\ntags: [sharing, access]\n---\n\n# Revocation ends a grant\n\nWhen an owner revokes a grant, the person loses access on their next request — no\ngrace period and no scheduled cleanup.\n\nThis rules out a \"revoked but valid until midnight\" state, which we considered\nfor shared days and rejected: two people disagreeing about who can see today is\nworse than one of them losing access mid-session.\n```\n\nFrontmatter fields:\n\n| Field | Meaning |\n| ------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `type` | **The only required field.** `slice`, `entity`, `decision`, `note`, `overview`. Lowercase — gates compare it literally. |\n| `title` | What to call the note in a listing. Falls back to the first heading, then the filename. |\n| `description` | One line, used as the note's subtitle in a section index. |\n| `resource` | Comma-separated `<kind>:<id>` URIs — the code this note is about. See below. |\n| `tags` | Flow list (`[a, b]`) or a `- item` block; both are read. |\n| `timestamp` | When it was written, if it matters. |\n\n`index.md` and `log.md` are **reserved**: an `index.md` maps a directory, a `log.md` is an append-only record. Neither is ever listed as a note by an index.\n\nPlain markdown links between notes — `[revocation](../decisions/revocation-ends-a-grant.md)` — are what make the base a graph. A link to a note that does not exist yet is legal: it marks something worth writing, not an error.\n\n## The layout\n\n```\nknowledge/\n index.md # type: overview — the map\n slices/\n index.md\n 01-the-daily-entry.md # type: slice\n entities/\n index.md\n entry.md # type: entity\n decisions/\n index.md\n revocation-ends-a-grant.md # type: decision\n security/\n index.md\n one-account-one-person.md\n questions/\n index.md\n who-owns-a-shared-day.md # type: note\n wishlist/\n index.md\n export-to-a-calendar.md # type: note\n```\n\nEach section answers exactly one question, which is what lets a reader find a note without an index of indexes:\n\n| Section | The question it answers |\n| --------------------- | ------------------------------------------------------------------ |\n| `slices/` | What is one buildable piece of this app, and what proves it works? |\n| `entities/` | What is this thing, in the words users use for it? |\n| `decisions/` | What was chosen, and what does that rule out? |\n| `decisions/security/` | Who may do what? |\n| `questions/` | What has been asked and not yet answered? |\n| `wishlist/` | What does somebody want that nobody has asked to be built? |\n\n**Create a section the turn you have a note for it** — never a scaffold of empty directories, and never a section without its own `index.md`. A section index says in one line what belongs in it; that sentence is the reason the file exists, so `pikku knowledge index` writes only the note listing and leaves your prose alone.\n\n## Slices\n\nA slice is the one note type that is a piece of _work_ rather than a fact, so it alone carries state and size:\n\n````markdown\n---\ntype: slice\ntitle: The daily entry\ndescription: An owner writes one entry per day, and sees it on the day.\nstatus: proposed\nentities: entry, day\nresource: func:createEntry\n---\n\n# The daily entry\n\nAn owner writes at most one entry per day. Writing again replaces it.\n\n```gherkin\nGiven 'owner' has no entry for today\nWhen 'owner' writes one\nThen it appears on today's day\nAnd writing again replaces it rather than adding a second\n```\n````\n\n- **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.\n- **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.\n- **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.\n\n## Showing it\n\nA note is markdown, and four kinds of block are **drawn** rather than printed. Every one of them degrades to something readable — a diagram falls back to its source, a callout to a blockquote, a decision to a code block — so writing one costs nothing where it is not rendered.\n\nNone of this changes the governing rule. A diagram of the schema is still a copy of `pikku meta` that drifts, and it drifts while looking more authoritative than prose would. These are for the part no generator can derive.\n\n**```mermaid — when the relationship is the point.** Prose is bad at graphs: \"an entry belongs to a day, a day belongs to an owner, and a grant lets another owner read a day\" is a sentence a reader has to re-read twice and draw themselves. Reach for one when a note is about how several things relate, an order of steps across time, or a state machine. Do not draw one thing, or two things and an arrow — that is a sentence.\n\n````markdown\n```mermaid\nflowchart LR\n owner -->|writes| entry\n entry -->|belongs to| day\n owner -->|grants read on| day\n```\n````\n\n**`> [!NOTE]` — when a line must survive skimming.** Five kinds: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Use one for the thing a reader who skips the paragraph must still not miss — a trap, a constraint that is easy to violate, an assumption the rest of the note rests on. Two callouts in a note is normal; six means the note has no prose left and nothing stands out.\n\n```markdown\n> [!WARNING]\n> A grant is checked on every request, not cached. A permission change is\n> immediate everywhere, and there is no invalidation step to forget.\n```\n\n**```decision — the answer a decision note owes.** `decisions/` answers \"what was chosen, and what does that rule out?\", and the second half is the half that gets dropped. The fence makes it checkable: `pikku knowledge validate` warns when a fence says what was chosen and never says what it closes off.\n\n````markdown\n```decision\nchosen: A revoked grant stops working immediately, everywhere.\nrules-out:\n - A \"revoked but valid until midnight\" state\n - A scheduled cleanup job\nbecause: Two people disagreeing about who can see today is worse than one of\n them losing access mid-session.\n```\n````\n\nIt is a **summary, not the note** — the argument continues in prose underneath. `rules-out:` takes one line or a `- item` block, and any value too long for one line wraps onto indented lines under it, as `because:` does above. A decision genuinely argued in prose needs no fence, and validate never asks for one; what it does ask is that a fence you did write is complete.\n\n**Fences of any other language are code** — highlighted and copyable, which is right for a snippet and wrong for a scenario or a decision, so do not put either in a bare fence.\n\n## `resource:` — tying a note to the code\n\n`resource:` names the code a note is about, as one or more `<kind>:<id>` URIs, comma-separated.\n\n**Every kind resolves.** That is the whole design: a kind that cannot be checked lets notes accumulate references nothing validates, and the graph rots into fiction exactly where it looks most authoritative.\n\n| Kind | An id is | Where it resolves |\n| ----------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |\n| `func:` | a function id | generated function meta |\n| `workflow:` | a workflow name | generated workflow meta |\n| `schema:` | a schema name | generated schemas |\n| `http:` | a route, `method:route`, or the function behind it | generated http wirings |\n| `queue:` | a queue name | generated queue wirings |\n| `cron:` | a scheduled task name | generated scheduler wirings |\n| `channel:` | a channel name | generated channel meta |\n| `table:` | a table name | the generated db schema |\n| `addon:` | `@pikku/addon-x` or bare `x` | the manifests that declare the dependency |\n| `scope:` | a scope name | the `scopes:` a function gates itself with, plus the scopes a `defineSystemRole()` confers |\n| `persona:` | a persona name | `definePersonas()` |\n\nIds are case-sensitive: `createEntry` is not `createentry`.\n\nThe check **fails closed on drift and open on ignorance**. An id missing from a kind that resolved is an error — the code was renamed or deleted under the note. A kind with no generated meta at all is skipped, so a project without queues is never told its queue references are broken.\n\nThere is no kind for a service, a middleware or a component. Say it in prose instead.\n\n## What never goes in a note\n\nThese are all things that exist somewhere better, so a note is always the copy that drifts:\n\n| Do not write | Because it lives in |\n| ----------------------------------- | ---------------------------------------------------------- |\n| a `personas/` section | `definePersonas()` in the project's own code |\n| a `scenarios/` section | the gherkin block inside the slice it belongs to |\n| a `permissions/` section | a decision note under `decisions/security/` |\n| a list of tables, columns or routes | `pikku meta` — the generated schema _is_ the schema |\n| a changelog | `CHANGELOG.md` at the repo root |\n| **secrets or credentials** | a secrets service. Never here — `knowledge/` is committed. |\n\nAnd two shapes that look like a knowledge base but are not:\n\n- **A flat `product.md` / `glossary.md` / `technology.md` at the root of `knowledge/`.** That is one long document: nothing can link into part of it, and no gate can read it. Split it into notes in the sections that answer its questions.\n- **A directory tree with no notes in it.** Sections exist because there is something to put in them.\n\n## The commands\n\n```bash\npikku knowledge validate # check the base against this profile\npikku knowledge index # refresh every index.md\npikku knowledge index --check # report stale indexes without writing (CI gate)\n```\n\n`validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, `decision` fences that state no `chosen:` or rule nothing out, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.\n\n`index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.\n\n## Profiles built on this one\n\nOKF permits frontmatter fields a reader does not know, and the parser ignores them rather than failing. That is the extension point: a tool layered on Pikku can add its own sections and fields on top of everything above without forking the format.\n\nFabric is the one that exists. It adds `decisions/design/` — rules about how the app looks and behaves — and a `design:` field on a slice pointing at the design options it was built from. Both are Fabric's to validate; `pikku knowledge validate` passes them through untouched. Everything else in this skill is the same in both.\n", "pikku-kysely/SKILL.md": "---\nname: pikku-kysely\ndescription: >-\n Use when WRITING KYSELY QUERIES (select/join/aggregate/insert/update/delete) inside a Pikku\n function body, or when setting up SQL database services with Kysely. Covers the query builder\n API (joins, aggregates + groupBy/having, returning, sql template, expression builder, $if,\n transactions, jsonArrayFrom relation helpers) AND @pikku/kysely service setup (channel stores,\n workflow services, secret services, AI storage, deployment services). TRIGGER when: writing any\n non-trivial kysely query (a join, an aggregate/count/sum, groupBy, subquery, transaction, or\n conditional query), the injected `kysely` service is used in a function body, or code uses\n PikkuKysely, KyselyChannelStore, KyselyWorkflowService, KyselySecretService, or the user asks\n about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB (use pikku-mongodb) or\n Redis (use pikku-redis).\ninstallGroups: [core]\n---\n\n# Pikku Kysely (SQL Database Services)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n## Writing Queries — the Kysely query builder\n\nIn a Pikku function body the injected `kysely` IS the `Kysely<DB>` instance — query it directly. Every connection factory below wires the **CamelCasePlugin** by default, so you write **camelCase everywhere in TS** (columns, aliases) and raw **snake_case ONLY inside a `` sql`` `` literal**. If a project opted out (`createNodeSqliteKysely({ camelCase: false })`), that inverts — check how the instance was built before assuming. Kysely is a query builder, NOT an ORM — there are no relations; shape nested data with the JSON helpers below. Never hand-roll SQL strings; never annotate the return type (in Pikku the output zod schema IS the type).\n\n```typescript\nimport { sql } from 'kysely'\n// Relation helpers are ENGINE-SPECIFIC — import the matching path:\nimport { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite' // SQLite / libSQL\n// import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres' // Postgres\n```\n\n```typescript\n// SELECT + where/orderBy/limit/offset. Terminals: .execute() | .executeTakeFirst()\n// | .executeTakeFirstOrThrow(() => new NotFoundError()) — pass an error factory.\nconst rows = await kysely.selectFrom('item')\n .select(['id', 'name', 'quantity'])\n .where('warehouseId', '=', warehouseId)\n .orderBy('name').limit(50).execute()\n\n// JOINS + aliased selects (qualify columns once a join exists)\nawait kysely.selectFrom('stock')\n .innerJoin('item', 'item.id', 'stock.itemId')\n .leftJoin('bin as b', 'b.id', 'stock.binId')\n .select(['stock.id', 'item.name as itemName', 'b.code as binCode'])\n .execute()\n\n// AGGREGATES via the fn helper + groupBy/having. eb.fn.count returns string|number —\n// cast if you need a JS number (SQLite: CAST(... AS INTEGER)).\nawait kysely.selectFrom('stock')\n .select((eb) => ['itemId', eb.fn.sum<number>('quantity').as('onHand')])\n .groupBy('itemId')\n .having((eb) => eb.fn.sum('quantity'), '<', 10) // low-stock\n .execute()\n\n// INSERT + RETURNING (one round-trip; works on SQLite & Postgres)\nconst created = await kysely.insertInto('item')\n .values({ name: input.name, warehouseId })\n .returning(['id', 'name']).executeTakeFirstOrThrow()\n\n// UPDATE + RETURNING, DELETE\nawait kysely.updateTable('item').set({ quantity: input.quantity })\n .where('id', '=', input.id).returning(['id', 'quantity']).executeTakeFirstOrThrow()\nawait kysely.deleteFrom('item').where('id', '=', input.id).execute()\n\n// EXPRESSION BUILDER for and/or; $if for conditional building; sql for raw fragments\nawait kysely.selectFrom('item')\n .selectAll()\n .where((eb) => eb.or([eb('quantity', '=', 0), eb('discontinued', '=', true)]))\n .$if(!!input.search, (qb) => qb.where('name', 'like', `%${input.search}%`))\n .select(sql<number>`quantity * unit_cost`.as('value')) // snake_case ok inside sql``\n .execute()\n\n// NESTED DATA (no relations) — jsonObjectFrom (one) / jsonArrayFrom (many)\nawait kysely.selectFrom('warehouse')\n .select((eb) => ['warehouse.id', 'warehouse.name',\n jsonArrayFrom(eb.selectFrom('bin').select(['bin.id', 'bin.code'])\n .whereRef('bin.warehouseId', '=', 'warehouse.id')).as('bins')])\n .execute()\n\n// TRANSACTION — multi-write atomicity. Use trx (not kysely) inside.\nawait kysely.transaction().execute(async (trx) => {\n await trx.updateTable('stock').set({ quantity: 0 }).where('itemId', '=', id).execute()\n await trx.insertInto('stockMove').values({ itemId: id, delta: -qty }).execute()\n})\n```\n\nPikku provides SQL database services through six packages:\n\n- `@pikku/kysely` — Base service implementations (database-agnostic), the serialize plugins, `createAuditedKysely` and the `pikkuSchemas` helpers\n- `@pikku/kysely-postgres` — PostgreSQL-specific implementations + the `PikkuKysely` connection wrapper and `PgEventHubService` (LISTEN/NOTIFY-backed)\n- `@pikku/kysely-mysql` — MySQL-specific implementations\n- `@pikku/kysely-sqlite` — SQLite-specific implementations, `createSQLiteKysely`, and the `LibsqlWebDialect`\n- `@pikku/kysely-node-sqlite` — `createNodeSqliteKysely` over `node:sqlite`, plus user-defined SQL functions and the coercion plugin\n- `@pikku/kysely-bun-sqlite` — the same over `bun:sqlite`\n\nThe last two are runtime adapters rather than service sets: they build the\n`Kysely<DB>` you inject into functions, while the dialect packages above supply\nPikku's own stores. They differ in one place — `bun:sqlite` cannot register\nscalar functions, so `createBunSqliteKysely` throws if you pass `functions`.\n\nAll implement standard Pikku interfaces from `@pikku/core`.\n\n## Installation\n\n```bash\n# Pick your database\nyarn add @pikku/kysely @pikku/kysely-postgres # PostgreSQL\nyarn add @pikku/kysely @pikku/kysely-mysql # MySQL\nyarn add @pikku/kysely @pikku/kysely-sqlite # SQLite (stores)\nyarn add @pikku/kysely-node-sqlite # SQLite on Node\nyarn add @pikku/kysely-bun-sqlite # SQLite on Bun\n```\n\n## API Reference\n\n### PostgreSQL Connection — `PikkuKysely`\n\n```typescript\nimport { PikkuKysely } from '@pikku/kysely-postgres'\n\nconst db = new PikkuKysely<DB>(\n logger: Logger,\n connectionOrConfig: postgres.Sql | postgres.Options | string,\n defaultSchemaName?: string,\n poolConfig?: PostgresConfig // maxPool, connectTimeout, idleTimeout, maxLifetime, prepare, statementTimeout\n)\n\nawait db.init()\ndb.kysely // Kysely<DB> instance for queries\nawait db.close()\n```\n\nIt builds a postgres.js-backed Kysely with the CamelCasePlugin. Pass an existing\n`postgres.Sql` when something else owns the pool — the wrapper then leaves it\nopen on `close()`. `poolConfig` keys are only forwarded when set, so postgres.js\nkeeps its own defaults for the rest, and it is ignored entirely when you hand in\nan already-constructed connection.\n\n### SQLite factories\n\n```typescript\nimport { createNodeSqliteKysely } from '@pikku/kysely-node-sqlite'\n\n// Your application DB — CamelCasePlugin on by default\nconst kysely = createNodeSqliteKysely<DB>({\n filename: 'app.db', // or ':memory:'\n camelCase: true,\n plugins: [], // layered on top\n functions: {}, // scalar UDFs, registered as deterministic (Node only)\n})\n```\n\n```typescript\nimport { createSQLiteKysely } from '@pikku/kysely-sqlite'\n\n// Pikku's own tables — returns Kysely<KyselyPikkuDB>, not your DB\nconst pikkuDb = createSQLiteKysely(database: SqliteDatabase | (() => Promise<SqliteDatabase>))\n```\n\nThese two are not interchangeable. `createSQLiteKysely` is typed to\n`KyselyPikkuDB` and wires the `SerializePlugin` (JSON columns in and out) rather\nthan the CamelCasePlugin, because it exists to back the stores below. Reach for\n`createNodeSqliteKysely` / `createBunSqliteKysely` for the instance your\nfunctions query.\n\n### Available Services\n\nEach database variant exports these services with a prefix (`Pg`, `MySQL`, `SQLite`, or base `Kysely`):\n\n| Service | Interface | Purpose |\n| --------------------- | ------------------------------------- | ---------------------------------------------- |\n| `*ChannelStore` | `ChannelStore` | WebSocket channel state persistence |\n| `*EventHubStore` | `EventHubStore` | Event hub state persistence |\n| `*WorkflowService` | `PikkuWorkflowService` | Workflow definition storage |\n| `*WorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |\n| `*DeploymentService` | `DeploymentService` | Deployment state management |\n| `*AIStorageService` | `AIStorageService, AIRunStateService` | AI conversation/run storage |\n| `*AgentRunService` | `AgentRunService` | Agent execution tracking |\n| `*SecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |\n\nA handful more live only on the base package — there is no `Pg`/`MySQL`/`SQLite`\nvariant to reach for, you import them from `@pikku/kysely` whatever the engine:\n\n| Service | Purpose |\n| -------------------------- | --------------------------------------------- |\n| `KyselySessionStore` | Persisted user sessions |\n| `KyselyScopeService` | Scope and role storage |\n| `KyselyWebhookService` | Webhook registrations and deliveries |\n| `KyselyCredentialService` | Encrypted third-party credentials |\n| `KyselyAIRunStateService` | AI run state (also implemented by AIStorage) |\n| `KyselyWorkflowMirror` | Mirrors workflow runs into queryable tables |\n| `KyselyAuditService` | Durable audit sink (see `pikku-audit`) |\n\nAll services take a `Kysely<KyselyPikkuDB>` instance in their constructor and have an `init()` method that creates tables if needed.\n\n### Secret Service\n\nEnvelope encryption: each secret gets its own DEK, wrapped by a KEK derived from\n`key` plus a stored per-version salt. Keeping `previousKey` around is what makes\nrotation possible — `rotateKEK` re-wraps every secret from the old key to the\ncurrent one and returns the new version, and it throws if no `previousKey` is\nconfigured.\n\n```typescript\nimport { PgKyselySecretService } from '@pikku/kysely-postgres'\n\nconst secrets = new PgKyselySecretService(db.kysely, {\n key: 'your-key-encryption-passphrase',\n keyVersion: 2, // defaults to 1\n previousKey: 'the-passphrase-you-are-rotating-away-from',\n audit: true, // log write/delete/rotate through the audit sink\n auditReads: false, // reads too — noisy, off by default\n})\nawait secrets.init()\n\nawait secrets.setSecret('api-key', { key: 'sk-...' })\nconst secret = await secrets.getSecret<{ key: string }>('api-key')\nawait secrets.hasSecret('api-key')\nawait secrets.deleteSecret('api-key')\nconst newVersion = await secrets.rotateKEK()\n```\n\n`getSecret` hands back a `SecretValue<T>`, not the bare value — it serializes as\n`[secret]` until something reveals it, which is what stops a secret drifting into\na log line or an audit row. See `pikku-config` for the reveal rules.\n\n## Usage Patterns\n\n### PostgreSQL Setup\n\n```typescript\nimport {\n PikkuKysely,\n PgKyselyChannelStore,\n PgKyselyWorkflowService,\n} from '@pikku/kysely-postgres'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const db = new PikkuKysely(logger, config.databaseUrl)\n await db.init()\n\n const channelStore = new PgKyselyChannelStore(db.kysely)\n await channelStore.init()\n\n const workflowService = new PgKyselyWorkflowService(db.kysely)\n await workflowService.init()\n\n return { config, logger, database: db, channelStore, workflowService }\n})\n```\n\n### SQLite Setup\n\n```typescript\nimport {\n createSQLiteKysely,\n SQLiteKyselyChannelStore,\n} from '@pikku/kysely-sqlite'\nimport Database from 'better-sqlite3'\n\nconst kysely = createSQLiteKysely(new Database('app.db'))\nconst channelStore = new SQLiteKyselyChannelStore(kysely)\nawait channelStore.init()\n```\n\n### MySQL Setup\n\n```typescript\nimport { MySQLKyselyWorkflowService } from '@pikku/kysely-mysql'\n\nconst workflowService = new MySQLKyselyWorkflowService(kyselyInstance)\nawait workflowService.init()\n```\n", "pikku-machine-auth/SKILL.md": "---\nname: pikku-machine-auth\ndescription: >-\n Use when authenticating a CLI/agent/service against a Pikku server, adding machine-to-machine\n (M2M) auth, issuing scoped API keys for sandboxes/agents/workers, or wiring better-auth sessions\n into Pikku middleware. Covers `pikku login` (device-authorization), the better-auth API Key\n plugin, machine identities, and `betterAuthSession` with the api-key branch. TRIGGER when: user\n asks about CLI login, `pikku login`, machine agents, service-to-service auth, API keys, client\n credentials, sandbox/worker tokens, or resolving a better-auth session in a Pikku function. DO\n NOT TRIGGER when: user asks about end-user HTTP session/cookie auth only (use pikku-http + the\n app betterAuth config) or about WebSocket channel mechanics (use pikku-websocket).\n---\n\n# Pikku Machine Auth\n\nUnified authentication for humans **and** machines against a Pikku + better-auth\nserver. Two paths, two headers, one resolver:\n\n| Caller | Credential | Header | Obtained by |\n|---|---|---|---|\n| **Human** (CLI, dev) | better-auth session token | `Authorization: Bearer <token>` | `pikku login` (device flow) → `~/.pikku/session.json` |\n| **Machine** (agent, sandbox, worker) | scoped API key | `x-api-key: <key>` | `createApiKey` (server-side, at provision/spawn) |\n\nBoth resolve to a Pikku `UserSession` through one middleware:\n`betterAuthSession({ mapSession, apiKey: { mapKey } })`.\n\n> The literal OAuth `client_credentials` grant is **not** implemented in\n> better-auth's oidc-provider. The API Key plugin gives the same capability (a\n> baked secret a service presents for scoped access), not the wire protocol.\n\n## Agent Operating Procedure\n\n1. Discover before editing — inspect the app's `betterAuth({ plugins: [...] })`\n config and existing middleware wiring before adding anything.\n2. Server changes go in the auth factory + a middleware wiring file; never put\n auth checks in a function body (use `permissions`).\n3. The API Key plugin contributes an `apikey` table — add the matching SQL\n migration and regenerate DB types before relying on it.\n4. Validate with the narrowest command, then `pikku all`.\n\n## Human path — `pikku login`\n\n```bash\npikku login --url https://app.example.com # device-authorization flow\npikku whoami # show current session + expiry\npikku logout # remove stored session\n```\n\n`pikku login` runs the RFC 8628 device flow: it requests a code, opens the\nbrowser to the verification URL, polls until you approve, then stores the\nsession token (keyed by base URL) at `~/.pikku/session.json` with its expiry.\n\n**Server requirement** — enable the `deviceAuthorization` and `bearer` plugins:\n\n```typescript\nimport { deviceAuthorization, bearer } from 'better-auth/plugins'\n\nbetterAuth({\n // ...\n plugins: [\n deviceAuthorization({ expiresIn: '5min', interval: '5s', schema: {} }),\n bearer(), // lets `Authorization: Bearer <session-token>` resolve a session\n ],\n})\n```\n\nThe browser approval is two steps the user's browser does automatically:\n`GET /auth/device?user_code=XXXX` (claims the code while signed in) then\n`POST /auth/device/approve`. The CLI only requests the code and polls\n`POST /auth/device/token`.\n\n## Machine path — API keys\n\nInstall the plugin (separate official package) and enable it:\n\n```bash\nyarn add @better-auth/api-key # peer: better-auth ^1.6.19\n```\n\n```typescript\nimport { apiKey } from '@better-auth/api-key'\n\nbetterAuth({\n plugins: [\n apiKey({\n enableMetadata: true, // REQUIRED to store scope on the key\n enableSessionForAPIKeys: true, // lets a key resolve via getSession too\n }),\n ],\n})\n```\n\n### Identity model\n\nA **machine is an API key, not a throwaway user.** Keys are owned by a small set\nof stable **service-user** identities you provision once (e.g. `orchestrator`,\n`machine-agent`, `builder`, `sandbox-runtime`). Per-machine scope rides on the\nkey's `metadata`/`permissions`. A key requires a real owning user row — minting\none for a non-existent `userId` is created but will not resolve.\n\n### Mint a scoped key (server-side, at spawn/provision)\n\n```typescript\n// `auth` is the better-auth instance (injected service)\nconst { key } = await auth.api.createApiKey({\n body: {\n userId: sandboxRuntimeUserId, // a stable service user\n name: `sandbox:${sandboxId}`,\n expiresIn: 60 * 60, // seconds\n metadata: { sandboxId }, // keep only STABLE ids here\n permissions: { sandbox: ['read', 'write'] },\n },\n})\n// inject `key` into the machine's env; it sends it as `x-api-key`.\n```\n\nRotate by minting a new key and expiring/deleting the old (`deleteApiKey`);\nmultiple active keys per identity allow zero-downtime rotation.\n\n### Resolve scope — `verifyApiKey`, not `getSession`\n\n`getSession(x-api-key)` returns only a bare mock session **without** the\nmetadata. Scope must come from `verifyApiKey`, which returns\n`{ valid, key: { userId, metadata, permissions } }`. The\n`betterAuthSession` api-key branch does this for you:\n\n```typescript\nimport { betterAuthSession } from '@pikku/better-auth'\nimport { addHTTPMiddleware } from '@pikku/core/http'\n\naddHTTPMiddleware([\n betterAuthSession({\n // human path: getSession result -> app session\n mapSession: ({ user }) => ({ userId: user.id }),\n // machine path: verified key -> app session. `services` lets you resolve\n // CURRENT scope (e.g. look up the owning row) instead of trusting only the\n // baked metadata.\n apiKey: {\n header: 'x-api-key', // default\n mapKey: async (key, services) => {\n const sandboxId = key.metadata?.sandboxId\n if (!sandboxId) return null // reject\n const row = await services.kysely\n .selectFrom('sandboxInstance')\n .innerJoin('sandbox', 'sandbox.id', 'sandboxInstance.sandboxId')\n .select(['sandbox.orgId', 'sandbox.projectId'])\n .where('sandboxInstance.sandboxId', '=', sandboxId)\n .where('sandboxInstance.stoppedAt', 'is', null)\n .executeTakeFirst()\n if (!row) return null\n return { userId: sandboxId, orgId: row.orgId, role: 'sandbox' }\n },\n },\n }),\n])\n```\n\nWhen the api-key header is present it is authoritative — the middleware never\nfalls through to `getSession` (a bare mock session would shadow the scoped one).\nWhen it is absent, the human `getSession` path runs as normal. Either way the\nmiddleware bails out entirely if a session is already set, and it checks the\n*live* session rather than the wire's construction-time snapshot, so it can't\nclobber one an earlier middleware resolved.\n\n### Restricting a key below its owner\n\nSet `scopes` on the session `mapKey` returns and that set is **authoritative** —\nincluding an empty one. It is never widened back out to everything the owning\nservice user holds:\n\n```typescript\nmapKey: async (key) => ({\n userId: 'sandbox-runtime',\n scopes: ['sandbox:read'], // this key can do only this\n})\n```\n\nLeave `scopes` unset for a key that acts with its owner's full rights. This is\nwhat makes one stable service user safely able to own keys of very different\npower — the restriction lives on the key, not on a proliferation of identities.\n\n### Failure handling is deliberately split\n\nA key that fails to verify is logged and treated as an ordinary \"not\nauthenticated\" — an unusable credential is not an outage. A failure *inside*\n`mapKey` (your scope store is down) propagates as a real error instead. That\nasymmetry is on purpose: a scope lookup that silently failed would serve the\nrequest anonymously, which is exactly the wrong direction to fail in.\n\n### `betterAuthStatelessSession` has no machine path\n\nThe lean cookie-cache middleware (`betterAuthStatelessSession` — no\n`services.auth()`, no DB) handles only the human path. Machine auth needs\n`betterAuthSession`, because `verifyApiKey` is a server call there is no\nstateless equivalent of. Both accept an `impersonation` option.\n\n### WebSocket channels authenticate on the upgrade handshake\n\nGenerated channel CLI clients attach the credential as a connection header\n(`x-api-key` for `PIKKU_API_KEY`, else `Authorization: Bearer` from\n`~/.pikku/session.json`). The `@pikku/ws` server copies the upgrade-request\nheaders into the channel's `http.request` and runs the inherited HTTP `*`\nmiddleware during `runUpgradeMiddleware`, so `betterAuthSession` resolves the\nsession before the channel opens. For this to work the app must register\n`betterAuthSession` via `addHTTPMiddleware([...])` (the `*` group) — not only on\nspecific routes — so it is inherited into the channel upgrade. Browser clients\ncannot set WebSocket headers, so header-auth only covers the Node CLI path; a\nbrowser channel needs a query-param/subprotocol vector instead.\n\n## Gotchas\n\n- `apiKey()` rejects `metadata` unless `enableMetadata: true`.\n- `deviceAuthorization()` requires a `schema` option (pass `schema: {}`).\n- Keep the two paths on **different headers** — `x-api-key` (machine) vs\n `Authorization: Bearer` (human). One header for both reintroduces ambiguity.\n- The `apikey` table is plugin-contributed — add the SQL migration + regen types.\n- `~/.pikku/session.json` is written `0600` and stores the token + expiry; the\n CLI uses the expiry to detect when a re-login is needed.\n", "pikku-mcp/SKILL.md": "---\nname: pikku-mcp\ndescription: >-\n Use when exposing Pikku functions as MCP tools, resources, or prompts for AI assistants. Covers\n mcp: true, pikkuMCPToolFunc, pikkuMCPResourceFunc, pikkuMCPPromptFunc, wireMCPResource,\n wireMCPPrompt, the MCP wire object and PikkuMCPServer. TRIGGER when: code uses mcp: true or any\n pikkuMCP*Func/wireMCP* helper, user asks about MCP, Model Context Protocol, AI tool integration,\n or exposing functions to Claude/ChatGPT. DO NOT TRIGGER when: user asks about AI agents (use\n pikku-ai-agent) or general function definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku MCP Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nExpose Pikku functions as Model Context Protocol (MCP) tools, resources, and prompts for AI assistants like Claude, ChatGPT, and others.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions that could become MCP tools\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## The shape of MCP in Pikku\n\nMCP has three surfaces, and Pikku wires them differently:\n\n| Surface | Function factory | Wiring | Return type |\n| --- | --- | --- | --- |\n| **Tool** | `mcp: true` on a `pikkuFunc`, or `pikkuMCPToolFunc` | none — the function *is* the registration | the func's own output, or MCP content blocks |\n| **Resource** | `pikkuMCPResourceFunc` | `wireMCPResource({ uri, title, … })` | `Array<{ uri, text }>` |\n| **Prompt** | `pikkuMCPPromptFunc` | `wireMCPPrompt({ name, description, … })` | `Array<MCPPromptMessage>` |\n\nTools are the odd one out — there is no `wireMCPTool`. Resources and prompts\ncarry protocol metadata (a URI template, a prompt name) that belongs to the\nendpoint rather than the implementation, so that metadata lives on the wiring and\nthe `pikkuMCP*Func` factory stays a plain function.\n\nImport every factory and wiring from `#pikku`.\n\n## API Reference\n\n### Tools\n\nAdd `mcp: true` to any existing function:\n\n```typescript\nexport const createTodo = pikkuFunc({\n description: 'Create a new todo item', // becomes the MCP tool description\n input: CreateTodoInput, // becomes the MCP tool input schema\n output: CreateTodoOutput,\n mcp: true,\n func: async ({ db }, { text, priority }) => db.createTodo({ text, priority }),\n})\n```\n\nA missing `description` is all an assistant has to go on, so codegen warns about\nit rather than failing — treat the warning as a bug.\n\nUse `pikkuMCPToolFunc` when the tool should control its own presentation. It\nreturns MCP content blocks (`{ type: 'text', text }` or `{ type: 'image', data }`\nwith base64), so the assistant reads prose rather than raw JSON:\n\n```typescript\nimport { pikkuMCPToolFunc } from '#pikku'\n\nexport const createTodoTool = pikkuMCPToolFunc({\n description: 'Create a todo item with title, priority, due date and tags',\n input: CreateTodoWithUserInputSchema,\n func: async (_services, input, { rpc }) => {\n const { todo } = await rpc.invoke('createTodo', input)\n return [\n { type: 'text' as const, text: `Created \"${todo.title}\" (${todo.id})` },\n ]\n },\n})\n```\n\nIt also accepts `name`, `title`, `summary`, `tags`, `middleware` and\n`permissions`. The function is sessionless and gets `mcp` and `rpc` on its wire —\ncalling existing business functions through `rpc.invoke` keeps the tool a thin\npresentation layer over logic that is already tested and reachable over HTTP.\n\n### Resources\n\n```typescript\nimport { pikkuMCPResourceFunc } from '#pikku'\n\nexport const getTodoResource = pikkuMCPResourceFunc<{ id: string }>(\n async (_services, { id }, { rpc, mcp }) => {\n const { todo } = await rpc.invoke('getTodo', { id })\n return [\n {\n uri: mcp.uri!,\n text: todo ? formatTodo(todo) : `Todo \"${id}\" not found.`,\n },\n ]\n }\n)\n```\n\nThe factory takes either a bare function (as above) or a config object — `{ func, name }`,\nor `{ func, input }` with a schema. A resource returns `Array<{ uri, text }>`;\nit is text only, with no blob variant. `mcp.uri` is the concrete URI the client\nasked for, which is why each entry echoes it back.\n\n```typescript\nimport { wireMCPResource } from '#pikku'\n\nwireMCPResource({\n uri: 'todos/{id}', // URI template\n title: 'Todo Details',\n description: 'Get details of a specific todo by ID',\n func: getTodoResource,\n tags: ['todos'],\n // also: summary?, mimeType?, size?, streaming?, errors?, middleware?\n})\n```\n\nEvery `{param}` in `uri` is checked against the function's input at compile time,\nso `todos/{id}` wired to a function whose input has no `id` fails to build rather\nthan handing the function an `undefined`.\n\n### Prompts\n\n```typescript\nimport { pikkuMCPPromptFunc, wireMCPPrompt } from '#pikku'\n\nexport const planDayPrompt = pikkuMCPPromptFunc({\n input: UserIdInputSchema,\n func: async (_services, { userId }, { rpc }) => {\n const { todos } = await rpc.invoke('listTodos', { userId, completed: false })\n return [\n {\n role: 'user' as const,\n content: {\n type: 'text' as const,\n text: `Plan my day:\\n${todos.map(formatTodo).join('\\n')}`,\n },\n },\n ]\n },\n})\n\nwireMCPPrompt({\n name: 'planDay',\n description: 'Generate a daily plan based on pending todos',\n func: planDayPrompt,\n tags: ['productivity'],\n})\n```\n\nA message's `role` is `'user' | 'assistant' | 'system'` and its `content.type` is\n`'text' | 'image'`. The prompt arguments the client sees are derived from the\ninput schema at codegen time: each property becomes a named argument, and\nschema-required properties become required arguments.\n\n### MCP Wire Object\n\nAvailable as `wire.mcp` inside any MCP function:\n\n```typescript\nmcp.uri // the resolved resource URI (resources only)\nmcp.sendResourceUpdated(uri) // notify clients a resource changed\nawait mcp.enableTools({ archiveTodos: true })\nawait mcp.enableResources({ todoDetails: false })\nawait mcp.enablePrompts({ planDay: true })\n```\n\nThe `enable*` calls are how a server presents a changing surface — hiding tools\nthat are meaningless in the current state beats letting the assistant call them\nand fail. Each returns a boolean, and each name is typechecked against your\ngenerated endpoint names.\n\n```typescript\nexport const deleteTodo = pikkuFunc({\n description: 'Delete a todo item',\n mcp: true,\n func: async ({ db }, { id }, { mcp }) => {\n await db.deleteTodo(id)\n mcp.sendResourceUpdated(`todos/${id}`)\n return { deleted: true }\n },\n})\n```\n\n## MCP Server Setup\n\n`PikkuMCPServer` takes the server config and a logger — not your services. It\nloads the generated `mcp.gen.json`, and the bootstrap import is what registers\nyour functions.\n\n```typescript\n// start.ts\nimport { PikkuMCPServer } from '@pikku/modelcontextprotocol'\nimport { createConfig, createSingletonServices } from './services.js'\nimport mcpJSON from '../.pikku/mcp/mcp.gen.json' with { type: 'json' }\nimport '../.pikku/pikku-bootstrap.gen.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst server = new PikkuMCPServer(\n {\n name: 'pikku-mcp-server',\n version: '1.0.0',\n mcpJSON,\n capabilities: { logging: {}, tools: {}, resources: {}, prompts: {} },\n },\n singletonServices.logger\n)\n\nawait server.init()\n\n// stdio — the transport desktop MCP clients spawn\nawait server.connectStdio()\nsingletonServices.logger = server.createMCPLogger()\n\n// …or streamable HTTP, for a hosted server\nconst { close } = await server.connectHTTP({ port: 3000, host: '127.0.0.1' })\n```\n\n`capabilities` is a filter, not documentation: a surface you leave out is not\nadvertised and its endpoints are never loaded, which is how you ship a tools-only\nserver.\n\nOver stdio the protocol owns stdout, so an ordinary console logger corrupts the\nframes — that is what `createMCPLogger()` is for. Swap the logger before\nanything logs.\n\n## Red flags\n\n| Symptom | Cause |\n| --- | --- |\n| `wireMCPTool` is not exported | There is no tool wiring — use `mcp: true` or `pikkuMCPToolFunc` |\n| `uri`/`title` rejected on `pikkuMCPResourceFunc` | Those belong on `wireMCPResource` |\n| Resource returning `{ uri, blob, mimeType }` | Resources are text only: `{ uri, text }` |\n| Client sees a tool with no description | `mcp: true` without a `description` — check the codegen warning |\n| stdio client disconnects on the first log line | Logger still writing to stdout; use `createMCPLogger()` |\n", "pikku-middleware/references/middleware-patterns.md": "# Middleware Patterns (extended)\n\nDetailed, less-common middleware recipes. The common-path bearer-auth pattern lives inline in SKILL.md; this file holds the client-side caller, session-setting, and audit recipes.\n\n## Service-to-Service: the client (caller) side\n\nUse the generated `RPCInvoke` type from `.pikku/rpc/pikku-rpc-wirings-map.gen.d.ts` — never hand-write the input/output types:\n\n```typescript\nimport type { RPCInvoke } from '../../backends/my-service/.pikku/rpc/pikku-rpc-wirings-map.gen.d.js'\n\nexport function getServiceRPC(baseUrl: string, token: string): RPCInvoke {\n return async (name: string, data?: unknown) => {\n const res = await fetch(`${baseUrl}/rpc/${String(name)}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ data: data ?? {} }),\n })\n if (!res.ok) {\n const text = await res.text().catch(() => '')\n throw new Error(`rpc ${String(name)} failed: ${res.status} ${text}`)\n }\n return res.json()\n } as RPCInvoke\n}\n```\n\n## Session-Setting Middleware\n\n```typescript\nconst apiKeyAuth = pikkuMiddleware(async ({ kysely }, { http, setSession, session }, next) => {\n if (session) return next() // already authenticated\n\n const header = http?.request?.header?.('x-api-key')\n if (!header) return next()\n\n const row = await kysely.selectFrom('apiKey').select('userId').where('key', '=', header).executeTakeFirst()\n if (row) setSession?.({ userId: row.userId })\n\n return next()\n})\n\naddTagMiddleware('api-key-auth', [apiKeyAuth])\n```\n\nFunctions tagged `'api-key-auth'` with `auth: true` reject requests without a valid key; those with `auth: false` can inspect the session but won't reject.\n\n## Request Logging / Audit\n\n```typescript\nconst auditLog = pikkuMiddleware(async ({ logger, db }, wire, next) => {\n const start = Date.now()\n await next()\n await db.createAuditLog({ duration: Date.now() - start })\n})\n\naddHTTPMiddleware('/admin/*', [auditLog])\n```\n", "pikku-middleware/SKILL.md": "---\nname: pikku-middleware\ndescription: >-\n Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware\n (including service-to-service bearer auth), per-route middleware, session-setting middleware, or\n understanding middleware execution order and priority. TRIGGER when: user wants middleware on\n some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global\n interceptors, or middleware priority/order questions. DO NOT TRIGGER when: user asks about\n permissions/authorization checks (use pikku-permissions), auth strategies like\n authBearer/authCookie (use pikku-security), or deployment.\ninstallGroups: [core]\n---\n\n# Pikku Middleware\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info middleware --verbose` and `pikku info tags --json` to understand the existing middleware and tag landscape.\n2. Identify the source files that own the behavior — wirings files, not generated output.\n3. Register middleware at module load time — in a `wirings/*.ts` file, never inside a function body.\n4. Validate: run `pikku all --tsc` after adding or changing middleware — it regenerates and then confirms type safety in one pass.\n\n## The `pikkuMiddleware` Factory\n\n```typescript\nimport { pikkuMiddleware } from '#pikku'\n\n// Simple: just a function\nconst myMiddleware = pikkuMiddleware(async (services, wire, next) => {\n // runs before the function\n await next()\n // runs after the function (optional)\n})\n\n// With metadata (name + priority)\nconst telemetryMiddleware = pikkuMiddleware({\n name: 'my-telemetry',\n priority: 'highest',\n func: async (services, wire, next) => {\n const start = performance.now()\n try {\n await next()\n } finally {\n services.logger.info({ duration: Math.round(performance.now() - start) })\n }\n },\n})\n```\n\nThe `wire` object gives you:\n- `wire.http` — inbound HTTP context (headers, URL, cookies)\n- `wire.setSession(session)` — set the session for this request\n- `wire.getSession()` — read the current session\n- `wire.session` — the session set so far (may be undefined)\n\nThrow a typed error to abort: `UnauthorizedError`, `ForbiddenError`, etc. from `@pikku/core/errors`.\n\n## Scoping: Five Levels\n\nFrom broadest to narrowest:\n\n```typescript\n// 1. Wire-agnostic global: all wire types (HTTP, Queue, Channel, Trigger, Workflow, ...)\naddGlobalMiddleware([telemetryOuter()])\n\n// 2. HTTP global: all HTTP routes\naddHTTPMiddleware('*', [cors(), authBearer()])\n\n// 3. Prefix-based: URL pattern\naddHTTPMiddleware('/admin/*', [auditLog])\n\n// 4. Tag-based: any wiring with matching tag\naddTagMiddleware('machine-agent', [bearerAuth]) // tag on function or wire\n\n// 5. Inline: per-wiring\nwireHTTP({\n route: '/books/:id',\n func: getBook,\n middleware: [cacheControl],\n})\n```\n\n## Global Middleware (`addGlobalMiddleware`)\n\nRuns before everything else, across every wire type: HTTP, Queue, Channel, Trigger, Scheduler, Workflow, Agent, CLI, MCP. Use it for cross-cutting concerns (e.g. telemetry) that must wrap every invocation regardless of transport.\n\n```typescript\nimport { addGlobalMiddleware } from '@pikku/core'\nimport { telemetryOuter, telemetryInner } from '@pikku/core/middleware'\n\naddGlobalMiddleware([telemetryOuter({ environmentId: env.STAGE_ID })]) // wraps the full call\naddGlobalMiddleware([telemetryInner({ environmentId: env.STAGE_ID })]) // closest to the function body\n```\n\n`telemetryOuter` ships with `priority: 'highest'`, `telemetryInner` with `priority: 'lowest'` — so priority sorting places outer first regardless of array/call order.\n\n## HTTP & Prefix Middleware (`addHTTPMiddleware`)\n\n```typescript\nimport { addHTTPMiddleware } from '@pikku/core/http'\nimport { cors, authBearer } from '@pikku/core/middleware'\n\n// All routes\naddHTTPMiddleware('*', [cors({ origin: 'https://app.example.com', credentials: true })])\n\n// Scoped to /api/* prefix\naddHTTPMiddleware('/api/*', [rateLimit({ maxRequests: 100, windowMs: 60_000 })])\n```\n\n## Tag Middleware (`addTagMiddleware`)\n\nTag middleware fires for any wiring (function or wire object) that carries a matching tag. This is the canonical approach for service-to-service bearer auth, rate limiting a group, or any cross-cutting concern scoped to a subset of routes.\n\n### Setting Tags\n\n```typescript\n// On the function definition\nexport const myFunc = pikkuSessionlessFunc({\n auth: false,\n tags: ['machine-agent'],\n func: async (services, input) => { ... },\n})\n\n// On the wire object\nwireHTTP({\n route: '/internal/action',\n method: 'post',\n auth: false,\n tags: ['internal'],\n func: myFunc,\n})\n```\n\nTags from the function definition and the wire object are merged — middleware from both tag sets runs.\n\n### Registering Tag Middleware\n\n```typescript\nimport { addTagMiddleware } from '#pikku'\n\naddTagMiddleware('machine-agent', [machineAgentBearerAuth])\n```\n\nCall at module load time — typically in the same `wirings/*.ts` file as the `wireHTTP` calls that use the tag.\n\n## Middleware Execution Order\n\nResolution happens in two steps, and the order matters more than it looks.\n\n**Step 1 — collect, broadest → narrowest:**\n\n```text\nglobal → httpGroup/* → httpGroup/prefix → wiringTags → wiringMiddleware → funcTags → funcMiddleware → function body\n```\n\n**Step 2 — sort that whole flat list by priority:**\n\n```text\nhighest → high → medium (default) → low → lowest\n```\n\n**Priority is the primary key across every scope, not within one.** The collected\nlist is flattened first and sorted once, so a `priority: 'lowest'` global\nmiddleware runs *after* an inline per-route middleware of default priority — the\nnarrower scope does not win. Scope order survives only as the tiebreaker between\nmiddleware of equal priority, because the sort is stable.\n\nThis is what makes `telemetryOuter`/`telemetryInner` work: they pin themselves to\n`highest`/`lowest` so they bracket every other middleware no matter where those\nwere registered.\n\nSet priority using the config-object form of `pikkuMiddleware`:\n\n```typescript\nconst earlyMiddleware = pikkuMiddleware({\n name: 'early',\n priority: 'highest', // 'highest' | 'high' | 'medium' | 'low' | 'lowest'\n func: async (services, wire, next) => { ... },\n})\n```\n\nWithin the same priority level, the collection order above is preserved. Use priority when a middleware must run before/after others regardless of where it was registered (e.g. telemetry wrapping everything, session extraction before auth checks).\n\n## Service-to-Service Bearer Auth (canonical pattern)\n\nA server that exposes RPCs only to a trusted caller (e.g. an API calling a machine-agent). Auth lives in a tag middleware — NOT in the function body. Authorization/permission checks belong in the `permissions` field (see `pikku-permissions`), never inside `func`.\n\n**On the server (the service being called):** tag the function, register a `pikkuMiddleware` that reads the `Authorization` header on that tag.\n\n```typescript\n// lib/host-token.ts\nlet _token: string | null = null\nexport const setToken = (t: string) => { _token = t }\nexport const getToken = () => _token\n```\n\n```typescript\n// wirings/http.wiring.ts\nimport { timingSafeEqual } from 'node:crypto'\nimport { addTagMiddleware, pikkuMiddleware } from '#pikku'\nimport { UnauthorizedError } from '@pikku/core/errors'\nimport { getToken } from '../lib/host-token.js'\n\nconst bearerAuth = pikkuMiddleware(async (_services, { http }, next) => {\n const authHeader = http?.request?.header?.('authorization') || http?.request?.header?.('Authorization')\n const token = getToken()\n const expected = token ? `Bearer ${token}` : null\n if (\n !expected ||\n !authHeader ||\n authHeader.length !== expected.length ||\n !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))\n ) {\n throw new UnauthorizedError()\n }\n return next()\n})\n\naddTagMiddleware('machine-agent', [bearerAuth])\n```\n\n```typescript\n// functions/my.function.ts\nexport const myFunc = pikkuSessionlessFunc({\n expose: true,\n auth: false,\n tags: ['machine-agent'],\n func: async (services, input) => { ... },\n})\n```\n\n**On the client (the caller):** use the generated `RPCInvoke` type — never hand-write a `fetch` wrapper's types. See `references/middleware-patterns.md`.\n\n## More patterns\n\n`references/middleware-patterns.md` covers the client-side `RPCInvoke` caller, session-setting middleware (set a session from an API key), and request logging / audit middleware.\n\n## After Changes\n\n```bash\npikku all # regenerate metadata so new tags are picked up\npikku all --tsc # regenerate, then type-check (fails on type errors)\n```\n", "pikku-mongodb/SKILL.md": "---\nname: pikku-mongodb\ndescription: >-\n Use when setting up MongoDB database services in a Pikku app. Covers PikkuMongoDB connection,\n channel stores, workflow services, secret services, AI storage, agent runs, and deployment\n services. TRIGGER when: code uses PikkuMongoDB, MongoDBChannelStore, MongoDBWorkflowService,\n MongoDBSecretService, or user asks about MongoDB setup with Pikku. DO NOT TRIGGER when: user\n asks about SQL databases (use pikku-kysely) or Redis (use pikku-redis).\n---\n\n# Pikku MongoDB\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/mongodb` provides MongoDB-backed implementations of Pikku's core service interfaces.\n\n## Installation\n\n```bash\nyarn add @pikku/mongodb\n```\n\n## API Reference\n\n### `PikkuMongoDB` (Connection Wrapper)\n\n```typescript\nimport { PikkuMongoDB } from '@pikku/mongodb'\n\nconst mongo = new PikkuMongoDB(\n logger: Logger,\n clientOrUri: MongoClient | string,\n dbName: string,\n options?: MongoClientOptions\n)\n\nawait mongo.init()\nmongo.db // Db instance for queries\nawait mongo.close()\n```\n\n### Available Services\n\n| Service | Interface | Purpose |\n| --------------------------- | ------------------------------------- | ---------------------------------------------- |\n| `MongoDBChannelStore` | `ChannelStore` | WebSocket channel state persistence |\n| `MongoDBEventHubStore` | `EventHubStore` | Event hub state persistence |\n| `MongoDBWorkflowService` | `PikkuWorkflowService` | Workflow definition storage |\n| `MongoDBWorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |\n| `MongoDBDeploymentService` | `DeploymentService` | Deployment state management |\n| `MongoDBAIStorageService` | `AIStorageService, AIRunStateService` | AI conversation/run storage |\n| `MongoDBAgentRunService` | `AgentRunService` | Agent execution tracking |\n| `MongoDBSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |\n| `MongoDBSessionStore` | `SessionStore` | Persisted user sessions |\n\nAll services take a `Db` instance in their constructor and have an `init()` method that creates collections/indexes.\n\n### Secret Service\n\nEnvelope encryption: `key` derives the KEK that wraps each secret's own DEK.\nKeeping `previousKey` set is what makes `rotateKEK()` possible — it re-wraps\nevery secret onto the current key and returns the new version.\n\n```typescript\nimport { MongoDBSecretService } from '@pikku/mongodb'\n\nconst secrets = new MongoDBSecretService(mongo.db, {\n key: 'your-key-encryption-passphrase',\n keyVersion: 2, // defaults to 1\n previousKey: 'the-passphrase-you-are-rotating-away-from',\n audit: true, // log write/delete/rotate through the audit sink\n auditReads: false, // reads too — noisy, off by default\n})\nawait secrets.init()\n\nawait secrets.setSecret('api-key', { key: 'sk-...' })\nconst value = await secrets.getSecret<{ key: string }>('api-key')\nawait secrets.rotateKEK()\n```\n\n## Usage Patterns\n\n### Full Setup\n\n```typescript\nimport {\n PikkuMongoDB,\n MongoDBChannelStore,\n MongoDBWorkflowService,\n} from '@pikku/mongodb'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n const mongo = new PikkuMongoDB(logger, config.mongoUri, 'myapp')\n await mongo.init()\n\n const channelStore = new MongoDBChannelStore(mongo.db)\n await channelStore.init()\n\n const workflowService = new MongoDBWorkflowService(mongo.db)\n await workflowService.init()\n\n return { config, logger, database: mongo, channelStore, workflowService }\n})\n```\n", "pikku-n8n-import/references/addon-mapping.md": "# Integration stub → Pikku addon\n\nTranslate n8n integration nodes (`gmailTool`, `slackTool`, `googleSheetsTool`, plain\n`gmail` / `slack` action nodes, etc.) that the importer left as throwing stubs into\nreal `ref('<addonRpc>')` references pointing at functions in **installed**\n`@pikku/addon-*` packages.\n\nThis is **per-stub mechanical**. Do not invent business logic, chain calls, or\n\"improve\" the workflow. Read one manifest entry, find the matching addon function,\nrewrite the stub.\n\n## Inputs\n\n1. **Manifest** — `<workflow>.integrations.json` next to the `.graph.ts`. Each entry:\n ```jsonc\n {\n \"rpcName\": \"agentGmailtool__sendAMessageInGmail\",\n \"n8nType\": \"n8n-nodes-base.gmailTool\",\n \"n8nName\": \"Send a message in Gmail\",\n \"parameters\": { \"sendTo\": \"...\", \"message\": \"...\", \"subject\": \"...\" },\n \"credentials\": { \"gmailOAuth2\": { \"id\": \"...\", \"name\": \"Personal Gmail\" } },\n \"isAgentTool\": true,\n \"agentName\": \"Inbox Assistant\"\n }\n ```\n2. **Installed addons** — `@pikku/addon-*` in the project's `package.json`\n `dependencies`. Map **only** against installed packages. If the addon for an n8n\n type is not installed, surface it (see SKILL step 4) — never silently skip and\n never pick a vaguely-named function from another addon.\n\n## Per entry, in order\n\n### Step 1 — identify the target addon\n\nMap `n8nType` to a package by reading its source. Common shapes (**guesses, not\nauthoritative** — always verify against installed source):\n\n| n8n type prefix | typical addon candidate |\n|---|---|\n| `n8n-nodes-base.gmail` / `gmailTool` | `@pikku/addon-email-gmail` |\n| `n8n-nodes-base.slack` / `slackTool` | `@pikku/addon-chat-slack` |\n| `n8n-nodes-base.googleSheets` / `…Tool` | `@pikku/addon-sheets-google` |\n| `n8n-nodes-base.notion` / `notionTool` | `@pikku/addon-docs-notion` |\n| `n8n-nodes-base.telegram` / `telegramTool` | `@pikku/addon-chat-telegram` |\n\nIf no installed addon plausibly covers the n8n type, stop and report it — do not\npick a wrong addon.\n\n### Step 2 — pick the function (resource + operation → fn name)\n\nn8n nodes use a `(resource, operation)` pair. Rubric:\n\n- `resource` defaults to the integration's primary noun if absent (gmail →\n `message`, slack → `message`, sheets → `spreadsheet`). Read the addon's folder\n structure (`messages/`, `drafts/`, `channels/`) to see what nouns exist.\n- `operation` is usually a verb (`get`, `getAll`, `send`, `delete`, `addLabels`).\n- The pikku function name is almost always `<resource><Verb>` in camelCase, matching\n the file's `export const` (`messageList` for `messages/list.function.ts`).\n\nVerify: `grep -h \"^export const\" <addonPkg>/src/functions/**/*.ts` and match by name.\nConventions in `@pikku/addon-email-gmail` (a **sanity reference**, not a fallback):\n\n- `getAll → <resource>List`, `get → <resource>Get`, `send → <resource>Send`,\n `delete → <resource>Delete`, `reply → <resource>Reply`\n- `addLabels → <resource>AddLabel` (singular!), `removeLabels → <resource>RemoveLabel`\n- `markAsRead / markAsUnread → <resource>MarkRead / <resource>MarkUnread`\n- `create → <resource>Create`\n\nIf the function has a `node:` block, prefer matching its `category`/`displayName`\nover guessing.\n\n### Step 3 — rewrite the stub\n\nTwo outcomes, by `isAgentTool`:\n\n**A) `isAgentTool: true`** — the stub is an agent tool referenced via `ref()`:\n1. Delete the stub file.\n2. In the agent file, replace `ref('agentGmailtool__sendAMessageInGmail')` in\n `tools: [...]` with `ref('messageSend')` (the resolved addon function).\n3. Ensure the addon is where pikku scans functions (usually automatic via\n `node_modules/@pikku/addon-*`).\n\nIf you can't delete safely, leave a one-line re-export instead of a stub:\n```ts\nimport { messageSend } from '@pikku/addon-email-gmail'\nexport const agentGmailtool__sendAMessageInGmail = messageSend\n```\nDefault is delete + retarget; wrappers add maintenance burden.\n\n**B) `isAgentTool: false`** — the stub is a graph node:\n1. Open `<workflow>.graph.ts`.\n2. In `nodes: { … }` find the entry whose value is the stub rpc name.\n3. Replace it with the addon function name (`'messageSend'`).\n4. If `config: { <id>: { input } }` produces an `{ items }` envelope, rewrite it to\n the addon function's real input schema (read its `input: z.object({...})`).\n5. Delete the stub file.\n\n### Step 4 — port hard-coded parameters\n\n- **Hardcoded values** (`\"limit\": 20`, `\"labelIds\": [\"INBOX\"]`) were user choices.\n Preserve them in the graph node's `input` (case B). **Agent tools cannot carry\n hardcoded params** (the LLM fills args at call time) — surface the trade-off; if\n the user needs a value pinned, they keep a thin wrapper.\n- **`$fromAI('Name', '', 'string')`** placeholders are LLM-filled — the addon's Zod\n schema becomes the tool schema. Just drop the placeholder string; no other action.\n\n### Step 5 — credentials\n\n`credentials: { gmailOAuth2: { id, name } }` is the n8n credential ref. Pikku addons\nexpect a wired service (`services.gmail`). Do **not** auto-wire — leave a TODO:\n\n> `// TODO: wire services.gmail using credential \"Personal Gmail\" (n8n id: gmail_cred_1) — see @pikku/addon-email-gmail/README.md`\n\n## Never\n\n- Invent functions that don't exist — grep first.\n- Pick a wrong addon because the right one isn't installed — report `npm i @pikku/addon-<x>`.\n- Bake per-mapping tables into `@pikku/n8n-import` — it is addon-agnostic; mapping lives here.\n- Modify the manifest — it's an audit artifact.\n- Chain calls — each entry maps to exactly one addon function.\n- Silently drop a hardcoded param — surface it.\n", "pikku-n8n-import/references/code-translation.md": "# n8n Code node → Pikku function\n\nReplace a Code-node stub's `throw new Error(...)` body with a faithful TypeScript\nreimplementation. The original JS is preserved verbatim in the JSDoc above the\nfunction. Keep the signature and JSDoc intact; only widen the Zod input/output if\nthe code's data shape demands it.\n\n**Narrow, mechanical translation** — behavioral parity, not better code. Do not\nrefactor, add error handling, or invent fields.\n\n## Process\n\n1. **Read the file.** Identify the input schema, output schema, the verbatim JS in\n the JSDoc, and the `pikkuSessionlessFunc` shape.\n2. **Determine the n8n mode** from the original JSON if available (importer leaves it\n in `fixtures/`, or ask). Two modes:\n - `runOnceForAllItems` (default) — code runs once with `items: Array<{ json, binary, pairedItem }>`, returns an array of envelopes.\n - `runOnceForEachItem` — runs once per item, `$json` / `$input.item.json` in scope, returns a single envelope.\n - If unknown, infer: bare `items.X` → all-items; bare `$json.X` / `$input.item.X` → each-item.\n3. **Apply the rubric.**\n4. **Edit only the function body.** Leave imports, schemas, JSDoc, name, description, refs untouched unless step 5 forces it.\n5. **If the schemas are wrong** (code reads `$json.userId: string` but input is `items: z.array(z.unknown())`), tighten with the smallest change. Prefer `z.unknown()` over `z.any()`. Never widen output to `z.any()`.\n6. **Add one comment** at the top of the body noting the mode: `// translated from n8n Code node, mode: runOnceForAllItems`. This is the *only* comment you may add.\n7. **Typecheck** (`yarn tsc` from the package root); fix errors with the smallest change.\n\n## Rubric\n\n### Envelope unwrapping — all-items mode\n\n| n8n | Pikku |\n|---|---|\n| `items` | `(data.items ?? []) as any[]` (or typed if known) |\n| `items[i].json.X` | `items[i].X` |\n| `items[i].json` | `items[i]` |\n| `items[i].binary` | **NOT supported** — leave a TODO and explain |\n| `items.length` | `items.length` |\n| `items.map(i => i.json.X)` | `items.map((i: any) => i.X)` |\n\n### Envelope unwrapping — each-item mode\n\n| n8n | Pikku |\n|---|---|\n| `$json.X` / `$input.item.json.X` | `data.X` (input is the item itself) |\n| `$input.item.json` | `data` |\n| `$input.all()` | not available per-item — change to all-items mode |\n\n### Return statement\n\n| n8n | Pikku |\n|---|---|\n| `return [{ json: X }]` | `return { items: [X] }` |\n| `return items.map(i => ({ json: ... }))` | `return { items: items.map(...) }` |\n| `return [{ json: X }, { json: Y }]` | `return { items: [X, Y] }` |\n| `return { json: X }` (each-item) | `return X` |\n| `return [...]` (already plain) | wrap in `{ items: [...] }` only if the output schema expects it |\n\n### Built-ins — do NOT auto-translate\n\nIf the code references any of these, **stop**, leave the body a stub, and annotate\n`// TODO:` + explain:\n\n- `this.helpers.*` (binary buffers, HTTP requests, prepareBinaryData)\n- `$node['Some Node'].json` (cross-node refs — resolve via Pikku `ref()` upstream, not in the body)\n- `$workflow`, `$execution`, `$item()`, `$items('Other Node')`\n- `getBinaryDataBuffer` / `getStaticData` — no equivalent, TODO\n- `require()` / dynamic `import()` — flag and stop\n\nTranslate these only when reachable: `$now`/`$today` → `new Date()`; `$env.X` →\n`services.variables.get('X')` (never `process.env` — Pikku house rule; `services` is\nthe first param).\n\n### Async / types\n\n- Original uses `await` → the body is already `async`; keep every `await`.\n- `this.helpers.httpRequest(...)` → do NOT inline; the user should use a separate\n `httpRequest` rpc node. Leave a `// TODO:` and explain.\n- Cast `items` as `any[]` only if the schema is `z.array(z.unknown())`; use the\n inferred type if tightened. Never `as any` on the return — fix the schema instead.\n\n## Example\n\nBefore (stub):\n```ts\n/**\n * STUB — generated from n8n Code node \"Custom Code\".\n * const total = items.reduce((acc, i) => acc + i.json.amount, 0);\n * return [{ json: { total } }];\n */\nexport const codeStubCustomCode = pikkuSessionlessFunc({\n input: CodeStubCustomCodeInput,\n output: CodeStubCustomCodeOutput,\n func: async (_services, _data) => {\n throw new Error('Stub: ported from n8n Code node \"Custom Code\" — implement me')\n },\n})\n```\n\nAfter:\n```ts\nexport const codeStubCustomCode = pikkuSessionlessFunc({\n description: 'Ported from n8n Code node \"Custom Code\"',\n input: CodeStubCustomCodeInput,\n output: CodeStubCustomCodeOutput,\n func: async (_services, data) => {\n // translated from n8n Code node, mode: runOnceForAllItems\n const items = (data.items ?? []) as any[]\n const total = items.reduce((acc, i) => acc + i.amount, 0)\n return { items: [{ total }] }\n },\n})\n```\n\n## Report\n\nTerse: the mode you inferred (one sentence), the literal rubric translations\napplied, anything flagged TODO and why, any schema tightening (before → after).\n\nDo not add tests, refactor, edit other files, \"improve\" the logic, or add\ntry/catch unless the original did. If the code is empty, comment-only, or so\ndependent on n8n internals that no honest translation is possible, leave the stub\nand tell the user which n8n features block it.\n", "pikku-n8n-import/references/loops-and-control.md": "# Loops & control stubs\n\nThe importer maps the mechanical control flow (IF/Filter/Switch it can normalize →\n`graph:branch`) but leaves the **semantic** cases as `control` stubs — chiefly\n**Loop Over Items / splitInBatches** and Switch in expression mode. These need\njudgment, which is why they are not compiled. Read the loop body and the workflow\naround it; decide, or ask.\n\n## Loop Over Items / splitInBatches\n\nn8n's loop node has two outputs: **loop** (output 1, fires per batch) and **done**\n(output 0, fires once when iteration finishes). The loop body flows from the loop\noutput back into the node — a cycle. Pikku graphs are a DAG, so **the loop becomes a\n`graph:map`** (`@pikku/addon-graph`) and the back-edge disappears:\n\n```ts\ntheLoop: \"graph:map\", // (graph:fanout) — one child invocation per item\n// config:\ntheLoop: {\n input: (ref) => ({\n items: ref(\"<predecessor>\"), // what fed the loop\n child: \"<childRpc-or-subGraph>\", // the loop body\n childInput: { /* $item-rebound body input */ },\n stepPrefix: \"theLoop\",\n }),\n next: \"<done-branch target>\", // output 0\n}\n```\n\nInside `childInput`, references rebind to the current element: the body's `$json` /\npredecessor and any `$('<loop node>')` become `$item`.\n\n### Decide the shape first\n\n| Loop body does… | Emit |\n|---|---|\n| transform each item independently (enrich, format, call one thing) | `graph:map` — child = the body |\n| accumulate across items (running total, build one object/array) | a **reduce**: a single generated function over the whole array, not a map — `graph:map` collects per-item results and *loses* the accumulator |\n| pure side-effect per item, nothing downstream consumes results | `graph:map` with **no `next`** (done branch empty) — the safest, unambiguous case |\n\n### Child arity\n\n- **Single-node body** → `child: \"<that node's rpc>\"`, its input as `childInput`.\n- **Multi-node body** → the child must be a per-item **sub-graph**. Lift the body\n into its own `pikkuWorkflowGraph` (see `pikku-workflow`) and set `child` to that\n workflow's registered name. If the body references nodes **outside** the loop\n (not just the item), that value has to be threaded in as `childInput` — if you\n can't do it cleanly, stop and ask rather than emit something subtly wrong.\n\n### Done-branch semantics (ask if it matters)\n\nn8n's done output is version-dependent: it may carry the *original* items or the\n*accumulated* results. `graph:map`'s `next` receives the array of child results.\nIf a downstream node reads that array's shape and the distinction matters, add:\n\n```ts\n// TODO(n8n): done branch receives collected loop results (not original items) — confirm this matches intent\n```\n\nand call it out in your summary. When the done branch is empty, there's nothing to\ndecide.\n\n### batchSize > 1\n\n`graph:map` is one-item-at-a-time. A real numeric `batchSize` (chunk into groups,\nrun the body per chunk) has no direct primitive — leave the stub, and tell the user\nthis loop batches N-at-a-time and needs a manual pass (or a `graph:chunk` +\n`graph:map` composition if the body is chunk-shaped).\n\n## Switch / control stubs the importer couldn't normalize\n\nA Switch in **expression mode** (routing by an arbitrary JS expression rather than\ncomparable conditions) stays a `control` stub. Options, in order of preference:\n\n1. If the expression is really a set of value comparisons, rewrite the node as a\n `graph:branch` by hand (see `pikku-workflow` for the `branch` shape) and wire the\n emitted `next` keys to the branch targets.\n2. If it's genuinely computed routing, translate the stub into a small function that\n returns the branch key, then feed it a `graph:branch`.\n3. If neither is faithful, leave the stub and explain what the Switch does.\n\n## Never\n\n- Emit a `graph:map` for an accumulator loop — you'll silently drop the running state.\n- Guess the done-branch semantics when a downstream node depends on the shape — mark\n it and surface it.\n- Invent a batching primitive — say what's unsupported instead.\n", "pikku-n8n-import/SKILL.md": "---\nname: pikku-n8n-import\ndescription: 'Use to import an n8n workflow JSON export into a runnable Pikku workflow. Triggers when the user says \"import this n8n workflow\", \"convert this n8n export to pikku\", points at an n8n `.json` export or a directory of them, or picks up after `pikku import n8n` left throwing stub functions (`STUB — generated from n8n …`, `— implement me`) or a `<workflow>.integrations.json` manifest. Owns the whole flow: run the importer, triage what it could not map, fill each stub, report any missing `@pikku/addon-*` integrations, and verify the result compiles and runs with no surviving stubs. DO NOT TRIGGER for hand-written addon wiring unrelated to an n8n import (use pikku-addon), or for authoring workflows from scratch (use pikku-workflow).'\nmetadata:\n version: 1.0.0\n---\n\n# n8n → Pikku Import\n\nTake an n8n export all the way to a compiling, stub-free Pikku workflow. The\n`@pikku/n8n-import` package (invoked by `pikku import n8n`) is **frozen**: it does\nthe provable, mechanical conversion and leaves everything it cannot prove as a\ntyped stub that throws at runtime. This skill runs that package, then fills the\nremainder with judgment, reports what needs a human decision, and verifies.\n\nNever re-do what the importer already did, and never hand-edit generated files to\npaper over a stub — fix the source cause (the stub function, the graph node, or a\nmissing dependency).\n\n## Agent Operating Procedure\n\n1. Discover before editing. Prefer `pikku-meta`/`pikku meta ... --json` when\n available; inspect only the focused output you need.\n2. Identify the source file that owns the behavior. Do not start from generated\n output, `.pikku`, `node_modules`, or vendored packages.\n3. Make the smallest source change that satisfies the task. Keep generated files\n generated.\n4. Validate with the narrowest relevant command first, then `pikku all` /\n `pikku-verify` when functions, wirings, or schemas changed.\n5. If validation fails, fix the source cause and rerun. Never edit generated\n files to hide an error.\n\n## Workflow\n\n### 1 — Run the importer (do as much as possible, cheaply)\n\n```bash\npikku import n8n <file> [--out <dir>] # -o for short\n```\n\nThe output directory is an **option**, not a positional argument; omitted, it\nfalls back to `scaffold.functionDir` from `pikku.config.json`, then cwd.\n\n`<file>` is one export, **or a directory** — the command reads every `.json` in\nit — and either form may hold a single workflow object, a bare array (`n8n\nexport:workflow --all`), or a `{ workflows: [...] }` wrapper. All of those are\nflattened into one import per workflow, so there is no need to loop yourself.\n\nIt writes `<slug>.graph.ts` (+ `.agent.ts` for AI workflows), `<slug>.addons.gen.ts`,\na `<slug>.integrations.json` manifest, and one stub function per node it could not\nmap. An un-importable workflow (a cross-workflow sub-workflow reference, a dynamic\nworkflow target, a mid-flow `respondToWebhook`) is reported as `[reason] message`\nand **skipped** — nothing partial is written for it. Across a batch the others\nstill import; the command exits 1 at the end if any failed, so read the log rather\nthan the exit code to know what landed. Relay every skipped workflow to the user.\n\n### 2 — Triage what it left\n\nEvery unmapped node is a stub that throws `… — implement me`. Classify each by its\nJSDoc marker and route to the matching reference:\n\n| Stub marker / signal | Handle via |\n|---|---|\n| `STUB — generated from n8n node \"…\" (type \"n8n-nodes-base.<svc>…\")` | `references/addon-mapping.md` |\n| `STUB — generated from n8n Code node \"…\"` | `references/code-translation.md` |\n| A `control` stub — Loop Over Items / **splitInBatches**, Switch expr-mode | `references/loops-and-control.md` |\n| `STUB — … vector-store … #902` | rare now (RAG ships as `<store>:query`/`:ingest`); a residual one = an unmapped store → report it, don't guess |\n| Importer `diagnostics` (already exited 1) | explain the reason; the workflow is un-importable as-is |\n\nRead a reference file only when you actually hit that stub class.\n\n### 3 — Fill each stub\n\nWork the manifest + stub files per the routed reference. The mechanical classes\n(addon, code) are near-deterministic; the loop/control class needs judgment\n(map vs reduce, done-branch semantics) — reference `loops-and-control.md` tells you\nwhen to decide vs ask.\n\n### 4 — Report missing integrations (first-class output)\n\nAn addon stub can only be wired to an **installed** `@pikku/addon-*`. When the\npackage for an n8n service is not in `dependencies`, do not guess a lookalike —\ncollect it. Give the user one upfront list:\n\n```\nMissing integrations — install these or the nodes stay stubs:\n • slackTool \"Post to channel\" → npm i @pikku/addon-chat-slack\n • hubspot \"Create contact\" → no @pikku/addon-hubspot exists yet\n```\n\n### 5 — Verify it works\n\n1. `pikku all` (regenerate) → `yarn tsc` from the package root; fix the source\n cause of any error and rerun.\n2. Grep the emitted functions for any surviving `— implement me`\n / `throw new Error('Stub:`. **Any survivor means the import is not done** —\n list them by node name.\n3. Green tsc **and** zero surviving stubs = success.\n\n## References\n\n| Open when you need to… | Read |\n|---|---|\n| map an integration stub (gmailTool, slackTool, googleSheets, plain action nodes) to an installed addon `ref(...)` | `references/addon-mapping.md` |\n| translate an n8n Code node body into a Pikku function body | `references/code-translation.md` |\n| lower a Loop Over Items / splitInBatches loop, or a Switch that stayed a stub | `references/loops-and-control.md` |\n\n## Final summary\n\nReport, terse:\n\n- Files written and workflow shape (`pure-graph` / `agent`).\n- Stubs filled, by class.\n- **Missing integrations** (the step-4 list) — the thing the user must act on.\n- Anything left as a `// TODO:` and why (credentials to wire, a loop deferred, an\n unmapped store).\n- Verification: `tsc` status + surviving-stub count (must be 0).\n", "pikku-n8n-import/SPEC.md": "# n8n → Pikku Import Specification\n\n## Intent\n\nTake an n8n workflow JSON export all the way to a compiling, stub-free, runnable\nPikku workflow. The `@pikku/n8n-import` package is treated as **frozen**: it does\nthe provable mechanical conversion and leaves everything it cannot prove as a typed,\nthrowing stub. This skill owns the end-to-end flow around it — run it, fill the\nremainder with judgment, report gaps that need a human decision, and verify.\n\n## Scope\n\nIn scope:\n- Running `pikku import n8n` and triaging its output.\n- Filling integration stubs (→ addon refs), Code stubs (→ function bodies), and\n loop/control stubs (→ `graph:map`/reduce/branch).\n- Reporting missing `@pikku/addon-*` integrations as a first-class output.\n- Verifying via `pikku all` + `tsc` + a zero-surviving-stub check.\n\nOut of scope:\n- Extending `@pikku/n8n-import` itself (it is frozen; do not add per-service tables\n or new compiler rules to it).\n- Authoring workflows from scratch (`pikku-workflow`) or hand-written addon wiring\n unrelated to an import (`pikku-addon`).\n- Inventing batching primitives or guessing ambiguous loop semantics — surface them.\n\n## Users And Trigger Context\n\n- Primary users: developers importing their own n8n workflows into a Pikku app,\n usually inside an agentic session.\n- Common requests: \"import this n8n workflow\", \"convert this n8n export to pikku\",\n finishing `— implement me` stubs, wiring a `*.integrations.json` manifest.\n- Should not trigger for: from-scratch workflow authoring, or addon wiring with no\n n8n import involved.\n\n## Runtime Contract\n\n- Required first action: run `pikku import n8n <export.json> [outDir]` (per file for\n a directory); relay any exit-1 diagnostic instead of scaffolding a partial.\n- Required outputs: filled stubs, a missing-integrations list, verification status.\n- Non-negotiable: never hand-edit generated files to hide a stub; map only to\n installed addons; zero surviving `— implement me` stubs at success.\n- Bundled files loaded at runtime: `references/addon-mapping.md`,\n `references/code-translation.md`, `references/loops-and-control.md` — each only\n when its stub class appears.\n\n## Source And Evidence Model\n\nAuthoritative sources:\n- `@pikku/n8n-import` codegen (stub markers, manifest shape, `import-n8n` command).\n- `@pikku/addon-graph` function contracts (`graph:map`/`fanout`, `branch`).\n- Installed `@pikku/addon-*` source (function names verified by grep, never guessed).\n\nUseful improvement sources: real imported workflows, harness coverage deltas,\naddon catalogue changes.\n\nData that must not be stored: credential secrets, customer data, private ids beyond\nwhat a manifest already records for reproduction.\n\n## Reference Architecture\n\n- `SKILL.md`: the run → triage → fill → report → verify workflow + router.\n- `references/`: per-stub-class depth (addon mapping, code translation, loops/control).\n\n## Validation\n\n- Lightweight: `yarn tsc` from the package root after each fill.\n- Deeper: `pikku all` regeneration; grep emitted functions for surviving stub throws.\n- Acceptance gates: green tsc **and** zero surviving `— implement me` stubs.\n\n## Known Limitations\n\n- `splitInBatches` with `batchSize > 1` and reduce-style accumulators have no direct\n primitive — surfaced, not auto-converted.\n- Missing addons block their nodes; the skill reports, it does not install.\n- Cross-workflow sub-workflow references fail import at the package level.\n\n## Maintenance Notes\n\n- Update `SKILL.md` when the import command, stub taxonomy, or verify gates change.\n- Update a reference when an addon convention, the `graph:map` contract, or a rubric\n changes.\n- This skill supersedes the former `pikku-n8n-addon-map` and `pikku-n8n-code-translate`\n skills (folded into `references/addon-mapping.md` and `references/code-translation.md`).\n", "pikku-paraglide/SKILL.md": "---\nname: pikku-paraglide\ndescription: 'Generate typed, static enum-label maps for a Paraglide i18n frontend with `@pikku/paraglide`, and reconcile them against the database enum columns so a label can never silently drift from a DB value. Enum-valued labels live under a reserved `enum__<group>__<member>` message namespace; the generator emits `i18n-enum.gen.ts` typed `satisfies EnumLabel<DbEnum>`. TRIGGER when: labelling an enum/status/kind/role value in a Paraglide app, replacing a dynamic `mKey(...)`/`m[...]` lookup with a static map, wiring `@pikku/paraglide` into Vite, or reconciling i18n against `CHECK (col IN (...))` / Postgres enum columns. DO NOT TRIGGER for plain free-text UI copy (that is a normal `m.some_key()` message), backend errors, or logs.'\ninstallGroups: [core]\n---\n\n# Pikku Paraglide enum labels\n\n## Agent Operating Procedure\n\nUse this as an execution checklist, not reference material.\n\n1. **Is the value an enum (a closed set — a status/kind/role/tag from a DB column or a fixed union)?** Then its label is a static map entry, never a dynamic lookup. Add `enum__<group>__<member>` keys to the catalog (`messages/en.json`) and read the value through the generated map: `group[value]()`.\n2. **Is the value free text or a one-off literal** (a heading, a button, a never-indexed label)? Then it is a normal Paraglide message — call `m.<key>()` directly. Do **not** invent an enum group for something that is always a literal.\n3. **Wire the generator** (`@pikku/paraglide/vite` or the CLI) so `i18n-enum.gen.ts` is regenerated from the catalog, and point it at the DB enums module (`enums.gen.ts`) so the maps are typed against the database.\n4. **Validate with the app's own `tsc`.** Reconciliation is enforced purely by types: a missing key or a dropped DB member is a compile error, and the deploy gate runs `tsc` before building. A clean `vite build` alone does not type-check.\n\n## The rules that don't change\n\n- **Never resolve an enum key dynamically.** No `mKey('status.' + value)`, no `m['enum__status__' + value]()`, no `mExists`/`mList` helpers. Dynamic keys can't be type-checked or tree-shaken. Everything is a static `m.<literal>()` reference, generated into the map.\n- **The `enum__<group>__<member>` namespace.** `__` separates the prefix / group / member segments; a single `_` joins words *within* a segment (`enum__booking_status__form_received`). The prefix (`enum`) and separator (`__`) are configurable but leave them at the defaults.\n- **Members must be valid JS identifiers.** Spell out leading digits — `two_guests`, not `2_guests`. The generator quotes an invalid member as a fallback but warns you to rename it.\n- **`asI18n(...)` is only for opaque server data** (names, slugs, ids returned from the API). Never `asI18n()` a hardcoded English string or an enum value — an enum value goes through its label map.\n\n## The generated module\n\n`i18n-enum.gen.ts` is **AUTO-GENERATED — do not edit.** It exports, per enum group:\n\n```ts\nimport { m } from './messages.js'\nimport type { I18nString } from '@pikku/react'\nimport type { BookingStatus } from '#pikku/db/enums.gen' // when reconciled\n\nexport type I18nMessage = () => I18nString\nexport type EnumLabel<E extends string> = Record<E, I18nMessage>\n\nexport const bookingStatus = {\n enquiry: m.enum__booking_status__enquiry,\n reserved: m.enum__booking_status__reserved,\n confirmed: m.enum__booking_status__confirmed,\n} satisfies EnumLabel<BookingStatus>\nexport type BookingStatusKey = keyof typeof bookingStatus\n```\n\n- Each value is an `I18nMessage` — a `() => I18nString` accessor. **Call it at render time** so the label tracks the active locale.\n- App code: `import { bookingStatus } from './i18n/i18n-enum.gen'` then `bookingStatus[value]()`.\n- For an open server value, gate it: `value in bookingStatus ? bookingStatus[value as BookingStatusKey]() : asI18n(value)`.\n\n### Module-scope hazard — store the accessor, don't call it\n\nA label used in a config built at module load (nav items, column defs) must hold the **accessor**, not the result — calling `m.foo()` at module scope freezes the label to the locale that was active at import:\n\n```ts\n// nav.config.ts\nconst items = [{ label: m.common__nav__items__dashboard /* ← reference */ }]\n// at render: <span>{item.label()}</span> // ← call here\n```\n\n## Reconciliation against the database\n\nThe DB column is the real source of truth for what an enum can be. The pikku CLI's db codegen emits a bare unions module — `.pikku/db/enums.gen.ts` — covering **both** Postgres native enums and SQLite `CHECK (col IN ('a','b',…))` constraints:\n\n```ts\nexport type BookingStatus = 'enquiry' | 'reserved' | 'confirmed' | 'ended' | 'cancelled'\n```\n\nPoint `@pikku/paraglide` at that file (`enumsFile`) and each catalog group whose member set **exactly matches** a DB enum is typed `satisfies EnumLabel<DbEnum>`. The label map then **is** the reconciliation — no separate assertion:\n\n- catalog drops a DB member, or `en.json` is missing the key → `m.enum__…` doesn't exist / `Record<DbEnum,…>` isn't exhaustive → **`tsc` error naming the gap**.\n- a DB enum with **no** catalog group → `unmatchedDbEnums: 'emit'` (default) generates a label map referencing `enum__<table>_<column>__<member>` keys, so `tsc` tells you exactly which keys to add; `'warn'` only reports it.\n- a group with a member the DB lacks (a *derived* UI state, e.g. a `waitlisted` view of a `pending` row) → a drift warning. Make that a **standalone `m.<key>()` message**, not an enum member — the enum group must mirror the DB column exactly.\n\nLabelling an enum that's never rendered costs nothing: Paraglide compiles only the messages actually referenced, so unused labels are tree-shaken away. So label every DB enum; don't add an opt-out.\n\n**To make a column an enum**, give it a closed domain in the migration so codegen can see it:\n- SQLite: `status TEXT NOT NULL CHECK (status IN ('enquiry','reserved','confirmed'))`\n- Postgres: a native `CREATE TYPE … AS ENUM (…)` column.\n\n## Wiring\n\n### Vite (dev + build)\n\nPlace `paraglideEnums` **after** `paraglideVitePlugin` (the generated file imports the compiled `m`). It regenerates on catalog/enums edits and only writes on change, so it never loops HMR.\n\n```ts\nimport { paraglideVitePlugin } from '@inlang/paraglide-js'\nimport { paraglideEnums } from '@pikku/paraglide/vite'\n\nexport default defineConfig({\n plugins: [\n paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),\n paraglideEnums({\n catalog: './messages/en.json',\n outFile: './src/i18n/i18n-enum.gen.ts',\n enumsFile: './packages/functions/.pikku/db/enums.gen.ts', // reconcile against the DB\n // enumsImport: '#pikku/db/enums.gen', // explicit specifier; defaults to a relative path\n }),\n ],\n})\n```\n\n### CLI (CI / non-Vite), run right after `paraglide-js compile`\n\n```sh\n# paraglide-enums <catalog.json> <out.gen.ts> [messagesImport] [enums.gen.ts]\nparaglide-enums ./messages/en.json ./src/i18n/i18n-enum.gen.ts ./messages.js ./packages/functions/.pikku/db/enums.gen.ts\n```\n\n`i18n-enum.gen.ts` is generated — gitignore it once the plugin/CLI runs in the build.\n\n## What NOT to do\n\n- Don't write `mKey`/`mList`/`mExists` or any `m[expr]()` dynamic lookup — every enum label is a static generated reference.\n- Don't introduce a literal-key indirection helper (`k('approve_enquiry')`); a literal is `m.approve_enquiry()` directly.\n- Don't call `m.foo()` at module scope for config built at import time — store `m.foo` and call it at render.\n- Don't put an extra UI-only member into an enum group to match a derived state — make it a standalone message and keep the group an exact mirror of the DB column.\n- Don't hand-edit `i18n-enum.gen.ts` or `enums.gen.ts` — fix the catalog / the migration and regenerate.\n", "pikku-permissions/SKILL.md": "---\nname: pikku-permissions\ndescription: >-\n Use when adding authorization checks to Pikku functions — pikkuPermission, pikkuAuth, scopes and\n defineScope, per-function permissions, global permissions, or understanding the scope/OR/AND\n gating logic. TRIGGER when: user wants to restrict who can call a function, check resource\n ownership, add role-based or scope-based access, declares or grants scopes, hits\n MissingScopeError, or asks where permission checks belong. DO NOT TRIGGER when: user asks about\n middleware or request interception (use pikku-middleware), authentication strategies (use\n pikku-security), or session management.\ninstallGroups: [core]\n---\n\n# Pikku Permissions\n\n## The Rule\n\n**ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**\n\nThis includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func` and is visible to the inspector, so the gate is declared rather than buried — which is what lets `pikku info permissions` and an audit see it at all. Alongside it sits `scopes` (see below) for grant-based gating; between them they are where Pikku enforces authorization. The one sanctioned exception is `permissionsInBody`, covered at the end.\n\n```typescript\n// CORRECT\nexport const deleteBook = pikkuFunc({\n func: async ({ db }, { bookId }) => {\n await db.deleteBook(bookId)\n },\n permissions: {\n owner: isBookOwner, // ← authorization here\n },\n})\n\n// WRONG — permission check inside func body\nexport const deleteBook = pikkuFunc({\n func: async ({ db }, { bookId }, { session }) => {\n if (!session) throw new UnauthorizedError() // ← never do this\n await db.deleteBook(bookId)\n },\n})\n```\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info permissions --verbose` and `pikku info functions --verbose` to understand what permissions are already defined and applied.\n2. Define permission checkers in a `src/permissions.ts` or domain-specific `src/lib/*-permissions.ts` file.\n3. Apply them via the `permissions` field on the function. For an app-wide baseline that every function must additionally satisfy, use `addGlobalPermission`.\n4. Validate: run `pikku all --tsc` to confirm permission checker signatures are correct.\n\n## Permission Factories\n\n### `pikkuAuth(fn)` — Session-Only Checks\n\nUse for checks that read the session but need no request data — and that assert\nsomething **beyond** merely having a session (a flag, a tier, a claim).\n\n```typescript\nimport { pikkuAuth } from '#pikku'\n\n// Good: a real gate on the session's contents, not just its existence.\nexport const isVerified = pikkuAuth(\n async (_services, session) => !!session?.emailVerified\n)\n```\n\n**Do NOT write an \"is signed in\" permission.** A checker that just returns\n`!!session` is not authorization — it re-checks authentication, which the\nfunction already enforces. A function that needs a signed-in user sets\n`auth: true` (the default for `pikkuFunc`); it does not also carry a\n`permissions: { signedIn }`.\n\n```typescript\n// WRONG — redundant with auth: true; adds a permission that gates nothing.\nexport const isSignedIn = pikkuAuth(async (_s, session) => !!session)\npikkuFunc({ auth: true, permissions: { signedIn: isSignedIn }, /* ... */ })\n\n// RIGHT — auth: true already requires the session; permissions are for capability.\npikkuFunc({ auth: true, /* ... */ })\n```\n\nA permission answers \"*may this user do this?*\" (role, ownership, tier) — never\n\"*is there a session?*\".\n\n### `pikkuPermission(fn)` — Data-Aware Checks\n\nUse when authorization depends on the actual request data (e.g., resource ownership).\n\n```typescript\nimport { pikkuPermission } from '#pikku'\n\nexport const isBookOwner = pikkuPermission(\n async ({ db }, { bookId }, { session }) => {\n const book = await db.getBook(bookId)\n return book?.authorId === session?.userId\n }\n)\n\nexport const hasBookAccess = pikkuPermission(\n async ({ db }, { bookId }, { session }) => {\n return await db.hasAccess(session?.userId, bookId)\n }\n)\n```\n\n## OR / AND Logic\n\n```typescript\npermissions: {\n verified: isVerified, // OR: verified users can access\n owner: isBookOwner, // OR: owners can access\n reviewer: [isVerified, hasBookAccess], // AND: both must pass\n}\n// Logic: verified OR owner OR (isVerified AND hasBookAccess)\n```\n\nGroups are OR'd. Entries within a group array are AND'd.\n\n## Where to Apply Permissions\n\n### Per-Function (preferred)\n\n```typescript\nexport const deleteBook = pikkuFunc({\n func: async ({ db }, { bookId }) => {\n await db.deleteBook(bookId)\n },\n permissions: {\n verified: isVerified,\n owner: isBookOwner,\n },\n})\n```\n\n### Global (`addGlobalPermission`) — App-Wide AND Gate\n\nA global permission is an app-wide baseline that **every** function must additionally pass. It is an independent AND gate: it can only ever *narrow* access — it never grants access a function's own `permissions` would deny.\n\n```typescript\nimport { addGlobalPermission } from '#pikku'\n\naddGlobalPermission([isEmployee]) // every function now also requires an employee session\n```\n\nMultiple `addGlobalPermission` calls accumulate and are AND'd together.\n\n> Wire-, tag-, and HTTP-route-level permissions (`addHTTPPermission`, `addTagPermission`, and a `permissions` field on HTTP/channel/MCP wirings) were **removed in #972**. Permissions now live only on the function definition, plus the optional global gate. Tags are organizational only — use tag/HTTP *middleware* (`addTagMiddleware`, `addHTTPMiddleware`) for cross-cutting request handling, not authorization.\n\n## Scopes — the AND Gate Above Permissions\n\nScopes answer \"what was this session granted?\" before permissions ask \"may this\nuser do this to this resource?\". They are AND-ed: every scope listed must be\nheld. Because they are checked first and fail closed, a scope can only ever\n*narrow* access — it never grants what `permissions` would deny.\n\nDeclare the scope tree once with `defineScope`. The body is a no-op that\ntree-shakes away; the CLI reads the call by AST and generates a `ScopeId` union,\nso a function naming an undeclared scope fails the build rather than silently\ngating on nothing.\n\n```typescript\n// src/scopes.ts\nimport { defineScope } from '#pikku'\n\ndefineScope({\n admin: {\n displayName: 'Administration',\n description: 'Administrative access',\n scopes: {\n invoices: {\n description: 'Invoice management',\n scopes: {\n create: { description: 'Create invoices' },\n void: { description: 'Void invoices' },\n },\n },\n },\n },\n billing: {},\n})\n```\n\nEvery node is grantable, keyed by segment: the above yields `admin`,\n`admin:invoices`, `admin:invoices:create`, `admin:invoices:void` and `billing`.\nScopes may be declared across more than one file — the declarations merge.\n\n```typescript\nexport const voidInvoice = pikkuFunc({\n scopes: ['admin:invoices:void'],\n permissions: { owner: isInvoiceOwner },\n func: async ({ db }, { invoiceId }) => { ... },\n})\n```\n\nA grant satisfies a required scope if it is the scope itself, an ancestor of it,\nor a wildcard at any level — so a session holding `admin` satisfies\n`admin:invoices:void`, and `admin:*` does too. A missing scope throws\n`MissingScopeError` naming the first one that failed.\n\n`scopes` requires a session and so is unavailable on `pikkuSessionlessFunc`:\nscopes fail closed, an anonymous caller holds none, and a sessionless function\nwith scopes would reject every caller it exists to serve. Gate those with\n`permissions`, which receive the optional session and may pass anonymous.\n\n## The Three Gates\n\nAuthorization is three independent gates, evaluated in this order, all of which must pass:\n\n1. **Scopes** (`scopes`) — AND'd, checked before input validation. Fails closed.\n2. **Global permissions** (`addGlobalPermission`) — AND'd together. A broad baseline that can only narrow access.\n3. **The function's own `permissions`** — OR'd groups (OR-of-ANDs), as above.\n\nThe gates are independent: a broad global (e.g. `isEmployee`) can **never** satisfy an admin-only function's own requirement. Each function still enforces its own `scopes` and `permissions` in full.\n\n## The Sanctioned Exception: `permissionsInBody`\n\nA few checks genuinely cannot be expressed as a permission — verifying a webhook\nsignature, a signed token, or an invite code, where the \"identity\" arrives in the\npayload and there is no session to check. For those, declare\n`permissionsInBody: true` on the function and keep the check in the body.\n\n```typescript\nexport const handleStripeWebhook = pikkuSessionlessFunc({\n permissionsInBody: true,\n auth: false,\n func: async ({ stripe }, data, { http }) => {\n stripe.webhooks.constructEvent(data.raw, http.request.header('stripe-signature'), secret)\n // ...\n },\n})\n```\n\nThis is a last resort, and it is purely declarative — it grants nothing and\nenforces nothing. Its only job is to tell the auditor that this function's\napparent openness is deliberate, so asserting it falsely disables the very check\nthat would have caught the mistake. It requires `\"allow\": { \"permissionsInBody\": true }`\nin `pikku.config.json`, which keeps the decision visible at the project level.\nPrefer `permissions` whenever the check can be expressed as one — they are\ndeclared, inspectable, and reusable.\n\n## Complete Example\n\n```typescript\n// src/permissions.ts\nimport { pikkuAuth, pikkuPermission } from '#pikku'\n\nexport const isVerified = pikkuAuth(\n async (_services, session) => !!session?.emailVerified\n)\n\nexport const isOrgMember = pikkuPermission(\n async ({ db }, { orgId }, { session }) => {\n return await db.isMember(session?.userId, orgId)\n }\n)\n\n// src/functions/org.function.ts\nexport const deleteOrg = pikkuFunc({\n func: async ({ db }, { orgId }) => {\n await db.deleteOrg(orgId)\n },\n permissions: {\n verified: isVerified,\n owner: [isVerified, isOrgMember],\n },\n})\n```\n\n## After Changes\n\n```bash\npikku all # regenerate if wirings changed\npikku all --tsc # regenerate, then verify permission checker types (fails on type errors)\n```\n", "pikku-pino/SKILL.md": "---\nname: pikku-pino\ndescription: >-\n Use when setting up structured logging with Pino in a Pikku app. Covers PinoLogger setup and log\n levels. TRIGGER when: code uses PinoLogger, user asks about structured logging, Pino, or\n @pikku/pino. DO NOT TRIGGER when: user asks about ConsoleLogger (use pikku-services) or general\n service setup.\n---\n\n# Pikku Pino (Structured Logging)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/pino` provides structured JSON logging via [Pino](https://getpino.io/). Implements the `Logger` interface from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/pino\n```\n\n## API Reference\n\n### `PinoLogger`\n\n```typescript\nimport { PinoLogger } from '@pikku/pino'\n\nconst logger = new PinoLogger()\n```\n\nNo constructor parameters. Creates a Pino logger instance.\n\n**Properties:**\n\n- `pino: pino.Logger` — Access the underlying Pino instance for advanced config.\n\n**Methods:**\n\n- `setLevel(level: LogLevel): void` — Set minimum log level.\n- `info(messageOrObj: string | Record<string, any> | Error, ...meta): void`\n- `warn(messageOrObj: string | Record<string, any> | Error, ...meta): void`\n- `error(messageOrObj: string | Record<string, any> | Error, ...meta): void`\n- `debug(message: string, ...meta): void` — string only; the object form is not accepted here\n\nEvery argument, first and trailing, is `Safe<>`-guarded. A `SecretValue` nested\nanywhere in what you log collapses the call to `never` and it stops compiling.\nAn unrevealed secret would print as `[secret]` regardless — the guard is what\nmakes logging one a deliberate act rather than an accident.\n\n`setLevel` maps Pikku's `LogLevel` enum onto Pino's own level strings, so pass\nthe enum (or its name) rather than a raw Pino level.\n\n## Usage Patterns\n\n### Basic Setup\n\n```typescript\nimport { PinoLogger } from '@pikku/pino'\n\nconst logger = new PinoLogger()\nlogger.setLevel('debug')\n```\n\n### With Pikku Services\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n return { config, logger }\n})\n```\n\n### Accessing Underlying Pino\n\n```typescript\nconst logger = new PinoLogger()\nlogger.pino.child({ module: 'auth' }).info('Token verified')\n```\n", "pikku-product-second-opinion/example/sample-report.md": "# Your app, in plain English — and where it could get better\n*A second opinion on the competitor-tracking system*\n\n> Worked example for the pikku-product-second-opinion skill. Shows the voice and the\n> layered structure on one real area (competitor tracking), drawn from a\n> pikku-software-archaeology blueprint + parity report. A full report would repeat\n> Part 2 for each major area, and cover every significant technology bet — not just\n> the one shown here.\n\n**How to read this:** no technical background needed. I'll explain what you have,\nwhat's solid, and what I'd change — and for each change, what it costs and what\nit buys you.\n\n## Part 1 — The short version\n\n**What you have.** A competitive-intelligence app: it watches your competitors'\nwebsites, spots meaningful changes (pricing, hiring, product updates), summarizes\nthem, and feeds your briefings and dashboards so your team knows first.\n\n**The headline.**\n- The hard part — reading messy websites and telling a real change from noise — is built well.\n- Until recently the app wasn't re-checking sites on its own at all *(now fixed)*.\n- When it does spot a change, the follow-up work only happens if someone clicks a button — so your dashboards can quietly go stale while looking current.\n\n**If it were me, this is the order I'd tackle things:**\n\n| Fix | Why it matters to you | Effort | Payoff |\n|---|---|---|---|\n| Turn on automatic checking | Sites weren't refreshing themselves | *Done* | High |\n| Make the follow-up automatic | Stops your intelligence going stale unnoticed | Medium | High |\n| Make failures visible | Problems surface instead of hiding | Small | Medium |\n\n---\n\n## Part 2 — Area by area\n\n### Competitor tracking\n\n**What this does.** Watches your competitors' sites for you and turns meaningful\nchanges into summaries your team can act on.\n\n**How it works today.** Like a clipping service: on a timer, the app re-reads\neach competitor's site, compares it to last time, decides whether anything\n*meaningful* changed (it ignores trivial edits), and writes up a summary when\nsomething real happens.\n\n**What's working.** The expensive, valuable part is solid — the app is genuinely\ngood at reading messy sites, separating real changes from noise, and summarizing\nthem. Keep it.\n\n**What's holding you back.**\n- **The automatic checking wasn't switched on.** The machinery existed but nothing\n pulled the trigger, so sites weren't refreshing on their own. What it means for\n you: your \"live\" intelligence wasn't live. Severity: Urgent. Effort: Small.\n *(Already fixed.)*\n- **The follow-up is manual.** When a change is found, updating your briefings and\n comparisons doesn't happen on its own — someone has to click \"regenerate.\" What\n it means for you: if nobody clicks, the dashboard shows old information while\n looking up to date, and you can't trust it. Severity: Serious. Effort: Medium.\n\n**How I'd do it differently — and why it's worth it.** Make the whole chain\nfinish as one task: when a change is found, the briefings and comparisons update\nautomatically as part of the same job, so \"done\" means \"your intelligence is\nactually current.\" And a failed step should show as a visible error, not vanish.\nThis is a **rewire, not a rebuild** — the valuable machinery stays; I'm connecting\npieces that already sit next to each other. Outcome: **more reliable, fewer\nsurprises** — which is the entire promise of the product.\n\n---\n\n### The technology bets\n\n**Pikku — the framework I'm suggesting you rebuild onto.** *Buys you:* one way to\nwrite a capability and drive it from anywhere — web, timers, background jobs,\nassistants — so the tracking rule is written once instead of three times, which is\nexactly the sprawl above. *Costs you:* it hasn't shipped a stable 1.0 (it's 0.12.x;\n0.13 is the first release promising backwards compatibility), so until then\nupgrades can break you — pin the version and budget for upgrade work. Its community\nand hiring pool are far smaller than the mainstream default's. That's normal for a\nyoung framework and survivable, but it's a real cost and it's yours to weigh.\n*Usually:* worth it when the problem is genuinely sprawl, as it is here — and worth\nwaiting if nobody has capacity to own upgrades.\n\n---\n\n## Part 3 — The fine print\n\n**How confident am I.** High on both problems — I can see them directly. The fix\nis a well-understood pattern rather than an experiment, though I'd want one live\ntest against your real data before calling it done.\n", "pikku-product-second-opinion/README.md": "# pikku-product-second-opinion\n\nTurns a `pikku-software-archaeology` blueprint into a **plain-language report for a\nnon-technical owner** — a founder/PM stuck with an app they didn't build.\nExplains how it works and how it could be better, in business terms.\n\n```\nExisting repo → pikku-software-archaeology → .knowledge/ blueprint → pikku-product-second-opinion → founder report\n (facts) (extract) (machine-readable) (translate + advise) (markdown + web page)\n```\n\n## The split from pikku-software-archaeology\n\n- **pikku-software-archaeology** extracts *facts* into `.knowledge/` for a machine (Pikku) to rebuild from. Engineer/generator audience.\n- **pikku-product-second-opinion** reads that blueprint and writes an *opinionated report* for a human to decide from. Non-technical audience.\n\nOne extracts; one advises. This skill consumes the other's output — it doesn't re-read the code.\n\n## What the report is calibrated to (locked by the author)\n\n- **Layered depth** — a one-page executive summary, then a section per major area for anyone who wants detail.\n- **Direct but fair tone** — names problems plainly, always with why-it-matters and credit for what's good.\n- **Both formats** — a markdown copy in the repo plus a clean, shareable web page (rendered via the `artifact-design` skill).\n\n## The rules that make it work\n\n1. **Translate, don't dump.** Every technical concept becomes a business outcome or a plain description. The jargon→plain table is in `SKILL.md`.\n2. **Every problem carries impact + severity + effort.** A problem with no \"what it means for you\" doesn't ship.\n3. **Always credit what works.** All-criticism reports get dismissed.\n4. **Argue improvements in business outcomes** (more reliable / faster / cheaper / safer / easier to hand off), and say whether each is a cheap **rewire** or an expensive **rebuild** — never recommend a rewrite just because the code is messy.\n5. **Mark confidence.** Certain and \"I'd need to check\" are different sentences.\n6. **Cover the frontend and the other ways the app is used** when the blueprint has them — walk the screens as a journey, call out consistency, and flag the custom-logic pieces (charts/tables/editors) as the real work vs the cheap standard pieces. Name the ways the product can be driven (people/web, developers/API+SDK, AI agents/MCP, power users/CLI) — often a genuine strength.\n7. **Give honest technology tradeoffs — both sides.** Every stack bet (framework, auth, hosting, key libraries) gets what-it-buys AND what-it-costs in business terms, tied to the founder's stage/goals. Don't cheerlead, don't trash, and **don't soften the disadvantages**. The app's own bets are derived from the blueprint (`architecture.json`/`integrations.json`/`frontend.json` + the repo's manifest) — never from a list in the skill, because a verdict you could write before reading the blueprint isn't a second opinion. Separately, and only when a rebuild is actually being recommended, the target stack (Pikku, Better Auth, TanStack Start) gets the *same* both-sides treatment with cons first-class — pinning someone's dependency for being pre-1.0 while staying quiet about the replacement being pre-1.0 too is a pitch, not an opinion.\n\n## Files\n\n```\npikku-product-second-opinion/\n├── SKILL.md # method + voice rules + red flags\n├── README.md # this file\n├── references/report-template.md # the layered structure to fill in\n└── example/sample-report.md # worked example (competitor-tracking area, founder voice)\n```\n", "pikku-product-second-opinion/references/report-template.md": "# Report template\n\nFill this in. Keep sentences short. Lead every point with what it means for the\nreader. Delete any section that would be empty rather than padding it.\n\n---\n\n# {App name}, in plain English — and where it could get better\n*A second opinion on {scope: the whole app / the competitor-tracking system / …}*\n\n**How to read this:** no technical background needed. Part 1 is the summary — if\nyou read nothing else, read that. Parts 2–3 go area by area for anyone who wants\ndetail.\n\n## Part 1 — The short version\n\n**What you have.** {2–3 sentences: what the product does, who uses it.}\n\n**The headline.** {3–5 bullets, one plain line each — the biggest risks and\nopportunities. No jargon.}\n\n**If it were me, this is the order I'd tackle things:**\n\n| Fix | Why it matters to you | Effort | Payoff |\n|---|---|---|---|\n| {…} | {business impact} | Small/Medium/Large | High/Medium/Low |\n\n---\n\n## Part 2 — Area by area\n\n### {Area name, in business terms — e.g. \"Competitor tracking\"}\n\n**What this does.** {the capability, as the business experiences it}\n\n**How it works today.** {a plain walkthrough — a small story beats a diagram}\n\n**What's working.** {genuine credit — the parts that are solid and worth keeping}\n\n**What's holding you back.**\n- **{Problem in plain terms}.** What it means for you: {business impact}.\n Severity: {Minor / Worth fixing / Serious / Urgent}. Effort to fix: {Small /\n Medium / Large}.\n\n**How I'd do it differently — and why it's worth it.** {the better design in\noutcomes: more reliable / faster to change / cheaper / safer / easier to hand\noff. Say whether it's a cheap rewire or an expensive rebuild.}\n\n{repeat per area}\n\n### The technology bets\n\n{One entry per significant choice — framework, sign-in, hosting, database, key\nlibraries — AND anything a rebuild would move them ONTO. Each gets both sides.}\n\n**{Technology}.**\n- *Buys you:* {in business terms}\n- *Costs you:* {in business terms — bills, hiring, shipping speed, upgrade work,\n the risk of betting on something young. Don't soften it. If it hasn't shipped a\n stable 1.0, say so and say what that means: pin the version, budget upgrades.}\n- *Usually:* {recommendation tied to their stage — normally \"keep it, watch this\"}\n\n{The same bar applies to anything you're recommending they move to. A stack you\npropose with no cons listed is a pitch, not a second opinion.}\n\n---\n\n## Part 3 — The fine print (optional)\n\n**How confident am I.** {where you're certain vs guessing; what you'd verify\nagainst real data before committing}\n\n**A few words explained.** {glossary — only terms that couldn't be avoided}\n", "pikku-product-second-opinion/SKILL.md": "---\nname: pikku-product-second-opinion\ndescription: 'Use when a non-technical owner (founder, PM, operator) wants a plain-language report on an app they hold but did not build — explaining how it works and how it could be better. Reads the .knowledge/ blueprint from pikku-software-archaeology and produces a layered, jargon-free report that credits what works, names what does not (with business impact + effort), and argues an opinionated better design. TRIGGER: \"explain how my app works\", \"what would you do differently\", \"review my app for a non-technical audience\", \"I inherited/am stuck with an agency-built app\", \"is this built well?\". DO NOT TRIGGER for: extracting the machine-readable blueprint itself (use pikku-software-archaeology), or an engineer-facing technical code review.'\ninstallGroups: [fabric]\n---\n\n# Product Second Opinion\n\n## Overview\n\nTurn an extracted product blueprint into a **report a non-technical owner can act on**. Two jobs, in one voice: (1) explain, in plain language, how the app they're stuck with actually works; (2) give an honest, opinionated second opinion — what's solid, what's holding them back, and how you'd build it better, argued in business outcomes, not architecture.\n\nThe reader is a founder/PM/operator, not an engineer. If they finish a section and don't know what it means for their business or what to do about it, the report failed — no matter how correct it is.\n\n**REQUIRED INPUT:** the `.knowledge/` blueprint produced by **pikku-software-archaeology**. If none exists, run that skill first — this one consumes its output (`product.json`, `domains.json`, `workflows.json`, `gaps.json`, `invariants.json`, `migration.json`, and any `parity-*.md`), it does not re-derive facts from the code. When the optional consumer-surface files are present (`interfaces.json`, `frontend.json`, `frontend-routes.json`, `frontend-components.json`), cover them too — see \"The frontend and the other ways your app is used\" and \"Technology choices\" below.\n\n## The cardinal rule: translate, don't dump\n\nEvery technical concept becomes a business outcome or a plain-language description. Never make the reader learn your vocabulary. If a term is unavoidable, define it in one clause the first time — but prefer describing the *effect* and skipping the term entirely.\n\n| Don't write | Write instead (describe the effect) |\n|---|---|\n| queue / worker / job | \"a background task that runs on its own\" |\n| workflow | \"a multi-step task that resumes where it left off if interrupted\" |\n| API / endpoint / route | \"something the app (or another tool) can ask it to do\" |\n| webhook | \"an automatic message the app sends to another tool when something happens\" |\n| event | \"a signal that something happened, that other parts can react to\" |\n| cron / scheduler | \"a timer that runs something on a schedule\" |\n| schema / migration | \"the shape of your stored data\" / \"a change to how data is stored\" |\n| auth / session / token | \"how the app knows who you are and what you're allowed to do\" |\n| refactor / rewire | \"reorganizing the inside without changing what it does\" |\n| cache | \"a saved copy kept around for speed\" |\n| race condition | \"two things happening at once and stepping on each other\" |\n| component | \"a reusable piece of the screen (a button, a chart, a table)\" |\n| route / page | \"a screen in the app\" |\n| design system / component library | \"the shared kit of screen pieces that keeps everything looking consistent\" |\n| SSR / SPA / rendering | \"how pages get built and shown\" (only mention if it affects speed or SEO) |\n| MCP server | \"a way for AI assistants to use your app's data and actions directly\" |\n| SDK | \"a ready-made toolkit so other developers can build on your app\" |\n| CLI | \"a way to drive the app by typing commands (for power users / automation)\" |\n| theme token / design variable | \"a single setting (like your brand color) reused everywhere, so you change it once\" |\n| modal / drawer | \"a pop-up box\" / \"a slide-out panel\" |\n\nWhen in doubt, say what the *user or the business* experiences, not what the machine does.\n\n## Report structure (layered — skim or dive)\n\nWrite these three parts in order. A reader can stop after Part 1.\n\n**Part 1 — Executive summary (one page).**\n- *What you have*: 2–3 sentences — what the product does and who uses it.\n- *The headline*: the 3–5 biggest risks/opportunities, one plain line each.\n- *Recommended order*: a table (Fix | Why it matters | Effort | Payoff). This is the part they act on.\n\n**Part 2 — One section per major area** (drive the areas from `domains.json`; skip domains with nothing worth saying). Each section follows this shape (see `example/sample-report.md`):\n- *What this does* — the capability in business terms.\n- *How it works today* — a plain walkthrough, ideally as a small story (\"on a timer, the app re-reads each site, compares…\").\n- *What's working* — genuine credit. Never skip this; a report that's all criticism gets dismissed.\n- *What's holding you back* — each problem MUST carry: **what it means for you** (business impact), **severity** (Minor / Worth fixing / Serious / Urgent), and **effort** (Small / Medium / Large).\n- *How I'd do it differently — and why it's worth it* — the opinionated part. Argue the improvement in one of these business outcomes: **more reliable / fewer surprises**, **faster to add features**, **cheaper to run**, **safer / less risk**, **easier to maintain or hand off**. Be explicit whether it's a cheap rewire or an expensive rebuild.\n\n**Part 3 — Appendix.**\n- *How confident am I* — REQUIRED. Where you're certain vs guessing; what you'd verify against real data first. The blueprint carries confidence tiers — anything you're relaying from a `low`/`medium` entry, or from a reconstructed (`explicit: false`) event, says so here.\n- *Glossary* (optional) — only for any term that slipped through.\n\n## Rewire vs rebuild (say which)\n\nThe blueprint's `migration.json` tells you which is which — `mappings[]` is what survives (each with its `recommendation`), `dropped[]` is what goes. The reader needs to know because the cost is 10× different.\n- **Rewire** — the valuable machinery exists; you're connecting pieces or turning something on. Cheap, low-risk. (Most \"it should be automatic but isn't\" findings are this.)\n- **Rebuild** — the capability doesn't exist or is fundamentally wrong. Expensive, risky. Reserve the word for when it's true; founders hear \"rewrite\" and panic or overspend.\n\nNever recommend a full rewrite because the code is messy. Messy-but-working is a rewire-over-time story, not a bonfire.\n\n## The frontend and the other ways your app is used\n\nWhen the blueprint has the consumer-surface files, add these to the report — they're often where a founder's questions actually live (\"why does the app feel inconsistent?\", \"can partners build on this?\").\n\n**The screens (`frontend-*.json`) — one area section, founder-framed.**\n- *What a user can do* — walk the main screens as a journey, not a component list.\n- *Consistency* — is it built from one shared kit of screen pieces, or a patchwork? A consistent kit means changes are cheap and the app feels coherent; a patchwork means every change is bespoke and the look drifts. Say which, plainly.\n- *The expensive pieces* — this is the key frontend insight. Most of the screen is standard pieces that are cheap to rebuild or restyle. A **small number carry real custom logic** — a bespoke chart, a complicated data table, a drawing/drag interaction, a rich editor. Those are the parts that take real effort to move or change, and the ones most likely to break. Name them, say what they do, and flag them as the real work — so nobody assumes \"it's just screens, it'll be quick.\"\n- *Design consistency (the \"it looks a bit off\" problems)* — from the blueprint's design findings, call out broken patterns in plain terms and, crucially, why each matters and roughly what it costs to fix. Common ones and how to frame them:\n - **The same action behaves differently in different places** (a slide-out panel here, a pop-up box there for the same task). *Why it matters:* the app feels inconsistent and users have to re-learn each screen. *Fix:* pick one pattern and apply it everywhere — cheap.\n - **Colors/spacing are hardcoded instead of set in one place.** *Why it matters:* changing your brand color, or fixing contrast, means hunting through every screen instead of editing one setting — slow and error-prone. *Fix:* move them to shared \"design tokens\" — a small, high-leverage cleanup.\n - **The same element looks different from page to page** (buttons, headings, cards). *Why it matters:* reads as unpolished and erodes trust, especially in a paid product. *Fix:* one shared version of each, reused — cheap and makes every future change faster.\n These are almost always **cheap rewires with an outsized polish/trust payoff**, not rebuilds. Give each an effort (usually Small–Medium) and say the payoff is perceived quality + faster future changes. Do NOT design-nitpick without a reason — every design point needs a \"why it matters to you.\" And credit consistency where the app already has it.\n- Frame rebuild/restyle work as **rewire vs rebuild**: restyling standard pieces to a consistent kit is cheap; re-creating a custom-logic piece is real engineering.\n\n**How your app can be driven (`interfaces.json`) — usually a short, positive section.**\nExplain, in one line each, the ways the product can be used: people through the web, developers through an API or toolkit, AI assistants through a direct connection (MCP), power users through the command line. This is often a genuine strength worth naming — an app that agents and partners can build on is more valuable than one only humans can click. But be honest about `status`: a connection that exists but only does two things is a *start*, not a feature — say so.\n\n## Technology choices — the honest tradeoffs (don't be cheap on the cons)\n\nThe app made specific technology bets. The founder deserves to know what each bet *bought* and what it *costs* — in business terms, tied to their situation (early vs scaling, chasing enterprise deals or not, big team or two people). Present every significant choice as a genuine tradeoff with **both sides**. Never cheerlead a technology, and never trash one — but do not soften the disadvantages to sound positive. A report that only lists upsides is not honest and is not useful.\n\nRules:\n- For each notable choice (framework, auth, hosting, database, key libraries): **what it buys** and **what it costs**, both in plain business terms, then a recommendation tied to *their* stage and goals — usually \"keep it, here's what to watch\" rather than \"switch.\"\n- Tie cons to consequences the founder feels: vendor bills, security/breach liability, hiring difficulty, how fast they can ship, enterprise-sales blockers, the risk of betting on something young.\n- Distinguish \"younger / smaller community\" (a real, manageable risk) from \"wrong choice\" (rare). Most stack choices are defensible; the job is informed eyes-open, not alarm.\n- **Verify before you disparage.** \"Don't be cheap on the cons\" means ACCURATE cons, not invented ones. Do NOT label a technology immature, niche, or feature-poor from vibes, its name, or its age — check its actual adoption, maturity, and feature set first. And separate an **inherent tradeoff of an approach** (e.g. self-hosting anything means you run and secure it) from a **deficiency of a specific tool** (often false — the tool may be mature and full-featured). Overstating cons is as dishonest as hiding them.\n- **Hold your own recommendation to the same bar.** If \"how I'd do it differently\" lands on a specific stack — Pikku included — it gets the same both-sides treatment as everything else, cons first-class. Pinning someone's dependency for being pre-1.0 while not mentioning that the replacement is pre-1.0 too isn't a second opinion, it's a pitch.\n\n- **Derive the app's choices from the blueprint, never from a list in this file.** Read the stack off `architecture.json`, `integrations.json`, `frontend.json`, and the manifest the repo actually has (`package.json`, `Gemfile`, `go.mod`, …). Cover the bets that are *load-bearing for this product*: typically the framework, the auth/identity approach, the datastore, the hosting/deploy model, the payment and other critical vendor integrations, and anything the blueprint marks `replacementDifficulty: hard`. A choice earns a paragraph if switching it would be expensive, or if living with it constrains the business — not because it appears in some canonical list. If you could write the verdict before reading the blueprint, you are not giving a second opinion.\n\n### The choices *this* app made\n\nWhatever the blueprint shows. A Rails app's bets are Rails, Devise, Pundit, MySQL, Sidekiq, its ERP and payment vendors; a Go app's are different again. Fill in buys/costs/usually for each, from evidence. If a legacy choice is working fine, credit it and move on — \"boring and working\" is a feature, and the pressure to find something to say about a stack is exactly what produces dishonest reports.\n\nWorth naming when it applies: adopting one coherent system in place of hand-rolled, drifted machinery (several ways of deciding who's an admin, a bespoke token table, a back-door test login, home-grown crypto) is a real reliability-and-security upgrade, not a lateral swap — say so when the blueprint's `gaps.json` and `policies.json` show that sprawl.\n\n### The stack a rebuild would land on\n\nInclude this section **only if you are actually recommending a rebuild** onto it — and then give every part of it the same both-sides treatment you gave the app's own bets, per the \"hold your own recommendation to the same bar\" rule above. These are not choices the app made; they are choices you are proposing, which is exactly why their costs are the reader's to weigh. The Pikku target stack is Pikku + Better Auth + TanStack Start + Mantine; the framings below are reference material for the parts you actually recommend, not a script to recite.\n\n**Better Auth (self-hosted sign-in) — instead of a paid service like Auth0/Clerk.**\n- *Buys you:* a mature, battle-tested, **framework-agnostic** library with a deep first-class plugin catalog — two-factor auth, multi-tenancy/organizations, multi-session, rate limiting, Stripe subscription billing, an admin panel, API keys for partners/automation, single-sign-on — plus a plugin system to add more without forking. You keep your users in your own database (single source of truth, no per-user bill that grows with success), with full control of the auth flows, and you can run it embedded in the app or as a standalone self-hosted auth server. So self-hosting here means neither giving up features nor rolling your own security.\n- *Cleans up messy auth (often the biggest win):* adopting it consolidates the kind of hand-rolled, drifted auth that accumulates in an older codebase — several different ways of deciding who's an admin, a bespoke token table, a back-door test login, home-grown encryption — into **one coherent system**. If the blueprint shows a before (legacy, bespoke) and after (on Better Auth), point at it directly: the sprawl collapses into a single well-structured setup. A concrete reliability-and-security upgrade, not just a swap.\n- *Costs you (the honest tradeoff — operational, not security-implementation):* the auth flows and security practices are handled by the library, so this is NOT \"build secure auth from scratch.\" What self-hosting means is you **operate** it — hosting, upgrades, uptime, and incident response sit with your team, where a paid SaaS runs that for you and bundles hosted extras (bot/anomaly detection, leaked-password monitoring, vendor compliance certifications) you'd otherwise operate and document yourself. You trade a per-user bill and vendor ops for control, data ownership, and predictable cost.\n- *Usually:* a strong default for an independent product — mature, full-featured, framework-agnostic, and frequently a genuine cleanup of inherited auth. The real question is who owns operating it, not whether the tool is good enough.\n\n**TanStack Start (the web framework) — instead of the incumbent (Next.js).**\n- *Buys you:* modern, tidy developer experience; strong type-safety that catches whole classes of bugs before users see them; fast iteration; fine-grained control; deploys well to modern/edge hosting. The libraries underneath it — the TanStack ecosystem (Query, Router, Table) — are mature, battle-tested and everywhere in React.\n- *Costs you (the honest tradeoff — maturity of the framework itself):* separate the ecosystem from the framework. Query/Router/Table are mature; the framework that wraps them is younger, and **you must check its release stage on tanstack.com/start at the moment you write** — do not infer it from the npm version. `@tanstack/react-start` has been on 1.x since early 2025 because its major tracks the **Router** version line, so \"1.168.x\" says nothing about whether Start itself has shipped a stable 1.0. If it is still pre-1.0, the cost is pinning an exact version and budgeting for upgrade work as it settles. Either way it is newer than the incumbent (Next.js), which has the largest ecosystem — fewer ready-made templates and third-party examples, and a smaller (though growing) pool of developers who've used *this specific* framework, which can make hiring slightly slower.\n- *Usually:* a credible, modern choice on a mature foundation. Whether it also carries pinning-and-upgrade risk depends on the release stage you just checked — say which you found, rather than repeating either verdict from here.\n\n**Pikku (the framework a rebuild would land on) — instead of staying where you are.**\n- *Buys you:* one way to write a capability and drive it from anywhere — web, background jobs, timers, realtime, AI assistants, the command line — so a feature is written once instead of five times. Type-safe clients and the API spec fall out of the code rather than being hand-maintained until they drift. The sprawl an organically-grown app accumulates collapses into one shape a small team can hold in its head.\n- *Costs you (the honest tradeoff — it is younger than anything it would replace):* Pikku has **not shipped a stable 1.0** — at the time of writing it is 0.12.x, and 0.13 is the first release that promises backwards compatibility; check the published version rather than repeating this one. Until then upgrades can break you. In practice: pin your version, budget for upgrade work, and know that the community, the ready-made examples, and the pool of developers who have used it are all far smaller than the incumbent's — smaller than TanStack Start's, let alone Next.js's. Being pre-1.0 is normal for a young framework, and survivable, but it is a real cost and it is the reader's to weigh, not yours to skip.\n- *Usually:* worth it when the real problem is sprawl — many surfaces, hand-maintained glue, the same rule implemented three slightly different ways — and the team wants one shape instead of five. Harder to justify for an app that works and needs a few rewires: those are usually cheaper in place. If nobody has capacity to own upgrades, that's a real reason to wait.\n\nCheck these statuses before you write them up rather than repeating them from here — a framework's release stage moves, and the point is the current fact, not this example.\n\n## Delivery\n\nProduce **both**:\n1. A **markdown** report in the repo (e.g. `docs/reports/<app>-second-opinion.md`) — versioned, diffable.\n2. A **shareable web page**: load the **artifact-design** skill, then render the same report as one clean, print-friendly, theme-aware page they can send to a cofounder or the agency. Same content, nicer to read.\n\n## Red flags — you're writing the wrong report\n\n| Symptom | Fix |\n|---|---|\n| A technical term with no translation | Rephrase as the effect on the user/business, or cut the term. |\n| A problem with no \"what it means for you\" | Incomplete — add the business impact or delete it. |\n| All problems, no credit | You'll lose the reader's trust. Name what's genuinely good. |\n| A recommendation with no effort + payoff | Not decision-useful. Add both. |\n| \"Rewrite the app\" | Almost always wrong. Separate rewire (cheap) from rebuild (dear); lean on what `migration.json.mappings` says survives. |\n| A guess stated as fact | Mark confidence. \"I'm certain\" and \"I'd need to check\" are different sentences. |\n| Only listed the upsides of a technology choice | Not honest. Every bet has a cost — name it in business terms, don't soften it to sound positive. |\n| Recommended a stack (including ours) without its cons | You applied a maturity bar to their technology and exempted your own. Both sides, or cut the recommendation. |\n| Trashed a technology as \"the wrong choice\" | Equally lazy. Most choices are defensible; frame as tradeoff + \"what to watch,\" not a verdict. |\n| \"The frontend is just screens, it'll be quick\" | Wrong. The custom-logic pieces (charts, complex tables, editors) are real work — flag them separately from the cheap standard pieces. |\n| A design point with no \"why it matters\" | Taste, not advice. Tie every design finding to user perception (polish/trust) or maintenance cost (change-once vs hunt-everywhere), plus effort. |\n| Reads like a code review | Wrong audience. Would a founder know what to *do* after this paragraph? |\n\n## Relationship to pikku-software-archaeology\n\n`pikku-software-archaeology` = facts → `.knowledge/` blueprint, for a machine to rebuild from. **This skill** = blueprint → opinionated report, for a human to decide from. One extracts; one advises. Run archaeology first (or point this skill at an existing `.knowledge/`), then translate its `gaps.json` + `invariants.json` + `migration.json` into the business-language report above.\n", "pikku-queue/SKILL.md": "---\nname: pikku-queue\ndescription: >-\n Use when adding background job processing, async task queues, or distributed workers to a Pikku\n app. Covers wireQueueWorker, job enqueuing, progress tracking, retries, BullMQ and PgBoss\n adapters. TRIGGER when: code uses wireQueueWorker, user asks about background jobs, task queues,\n async processing, BullMQ, PgBoss, or job retries. DO NOT TRIGGER when: user asks about scheduled\n cron tasks (use pikku-cron) or event-driven triggers (use pikku-trigger).\ninstallGroups: [core]\n---\n\n# Pikku Queue Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions as background queue workers. Supports job control (progress, retry, discard), configurable concurrency, and type-safe job publishing.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireQueueWorker(config)`\n\n```typescript\nimport { wireQueueWorker } from '@pikku/core/queue'\n\nwireQueueWorker({\n name: string, // Queue name (unique identifier)\n func: PikkuFunc, // Worker function\n config?: {\n batchSize?: number, // Total worker concurrency\n prefetch?: number,\n pollInterval?: number, // ms\n visibilityTimeout?: number, // seconds\n lockDuration?: number, // ms\n drainDelay?: number, // seconds\n removeOnComplete?: number, // how many completed jobs to RETAIN (a count, not an age)\n removeOnFail?: number, // how many failed jobs to RETAIN\n maxStalledCount?: number,\n autorun?: boolean,\n groupConcurrency?: number | GroupConcurrencyConfig, // must not exceed batchSize\n },\n})\n```\n\nNot every adapter supports every option. Each adapter declares a\n`QueueConfigMapping`, and unsupported keys are dropped with a warning rather than\nsilently ignored — so check the startup logs if a setting appears to have no\neffect.\n\n`groupConcurrency` limits how many jobs run concurrently *per group* (jobs\ncarrying a `JobGroup` with an `id` and optional `tier`), so one noisy tenant\ncannot consume the whole worker:\n\n```typescript\ngroupConcurrency: { default: 2, tiers: { enterprise: 10 } }\n```\n\n### Wire Object (`wire.queue`)\n\nInside queue worker functions:\n\n```typescript\nwire.queue.updateProgress(progress: number | string | object) // Report progress\nwire.queue.discard(reason?: string) // Silently discard job (throws QueueJobDiscardedError)\nwire.queue.fail(reason?: string) // Mark job as failed\n```\n\n`updateProgress` is not limited to a 0-100 percentage — a string or an object\nlets a long job report a stage (\"rendering page 4/20\") that a dashboard can show\ndirectly.\n\n### Job Publishing\n\n```typescript\nconst jobId = await queue.add(queueName, data, options?)\n```\n\nOptions:\n\n```typescript\n{\n retryAttempts?: number, // Max retry attempts\n retryDelay?: number, // Base delay in ms\n retryBackoff?: 'linear' | 'exponential' | 'fixed',\n deadLetterQueue?: string, // Where exhausted jobs land\n messageRetention?: number,// Seconds\n priority?: number, // Higher numbers run first\n fifo?: boolean,\n timeout?: number, // ms\n delay?: number, // ms before the job becomes eligible\n}\n```\n\n## Usage Patterns\n\n### Basic Queue Worker\n\n```typescript\nconst processReminder = pikkuSessionlessFunc({\n title: 'Process Reminder',\n func: async ({ db, emailService }, { todoId, userId }) => {\n const todo = await db.getTodo(todoId)\n await emailService.sendReminder(userId, todo)\n return { sent: true }\n },\n})\n\nwireQueueWorker({\n name: 'todo-reminders',\n func: processReminder,\n})\n```\n\n### Job Control (Progress, Discard, Fail)\n\n```typescript\nconst processReminder = pikkuSessionlessFunc({\n title: 'Process Reminder',\n func: async ({ db }, { todoId }, wire) => {\n await wire.queue.updateProgress(25)\n\n const todo = await db.getTodo(todoId)\n if (!todo) {\n await wire.queue.discard('Todo not found')\n return\n }\n\n if (todo.completed) {\n await wire.queue.fail('Todo already completed')\n return\n }\n\n await wire.queue.updateProgress(100)\n return { sent: true }\n },\n})\n```\n\n### Retries & Configuration\n\n```typescript\nwireQueueWorker({\n name: 'todo-reminders',\n func: processReminder,\n config: {\n batchSize: 5,\n removeOnComplete: 100,\n },\n})\n\n// Enqueue with retry options\nconst jobId = await queue.add(\n 'todo-reminders',\n {\n todoId: 'abc-123',\n userId: 'user-456',\n },\n {\n priority: 10,\n delay: 5000,\n retryAttempts: 3,\n retryBackoff: 'exponential',\n retryDelay: 1000,\n }\n)\n```\n\n### Type-Safe Queue Publishing\n\nAfter `npx pikku all`:\n\n```typescript\nimport { PikkuQueue } from '#pikku/pikku-queue.gen.js'\n\nconst queue = new PikkuQueue(queueService)\n\nconst jobId = await queue.add('todo-reminders', {\n todoId: 'abc-123',\n userId: 'user-456',\n})\n\nconst job = await queue.getJob('todo-reminders', jobId)\nconst status = await job.status() // 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'\nconst result = await job.waitForCompletion(30_000)\n```\n\n### Queue Adapters\n\n**BullMQ** (Redis-based):\n\n```typescript\nimport { BullMQQueueService } from '@pikku/queue-bullmq'\n\nconst queueService = new BullMQQueueService({\n connection: { host: 'localhost', port: 6379 },\n})\n```\n\n**PgBoss** (PostgreSQL-based):\n\n```typescript\nimport { PgBossQueueService } from '@pikku/queue-pg-boss'\n\nconst queueService = new PgBossQueueService({\n connectionString: 'postgres://...',\n})\n```\n\n## Complete Example\n\n```typescript\n// functions/email.functions.ts\nexport const sendWelcomeEmail = pikkuSessionlessFunc({\n title: 'Send Welcome Email',\n func: async ({ emailService, db }, { userId }, wire) => {\n await wire.queue.updateProgress(10)\n\n const user = await db.getUser(userId)\n if (!user) {\n await wire.queue.discard('User not found')\n return\n }\n\n await wire.queue.updateProgress(50)\n await emailService.send({\n to: user.email,\n subject: 'Welcome!',\n template: 'welcome',\n data: { name: user.name },\n })\n\n await wire.queue.updateProgress(100)\n return { sent: true, email: user.email }\n },\n})\n\n// wirings/queue.wiring.ts\nwireQueueWorker({\n name: 'welcome-emails',\n func: sendWelcomeEmail,\n config: { removeOnComplete: 100 },\n})\n\n// Enqueue from another function\nexport const registerUser = pikkuSessionlessFunc({\n title: 'Register User',\n func: async ({ db, queue }, { email, name }) => {\n const user = await db.createUser({ email, name })\n await queue.add('welcome-emails', { userId: user.id })\n return { user }\n },\n})\n```\n", "pikku-react/SKILL.md": "---\nname: pikku-react\ndescription: 'Set up @pikku/react in a React app: PikkuProvider context, createPikku factory, and the usePikkuRPC / usePikkuFetch hooks for direct (non-React-Query) calls. TRIGGER when: the user is bootstrapping a React frontend that talks to a Pikku backend, asks how to wire `PikkuProvider`, or needs to make one-off RPC calls outside of useQuery/useMutation. DO NOT TRIGGER when: the user is asking about useQuery/useMutation hooks (use pikku-react-query) or about workflows (use pikku-workflows-client).'\ninstallGroups: [core]\n---\n\n# Pikku React\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/react` is the smallest possible binding: a Context provider plus\ntwo hooks. It does **not** depend on React Query — that's a separate\nopt-in via the generated `api.gen.ts`. Use this skill when setting up the\nprovider or making direct RPC calls.\n\n## What ships\n\n```tsx\nimport {\n PikkuProvider,\n createPikku,\n usePikkuFetch,\n usePikkuRPC,\n usePikkuRealtime,\n usePikkuAgent,\n usePikkuWorkflow,\n asI18n,\n} from '@pikku/react'\n```\n\n`usePikkuRealtime` is only valid when you wired a `PikkuRealtime` class via\n`createPikku` — see the setup section. `usePikkuAgent` and `usePikkuWorkflow`\nare thin bindings over the RPC client that pin one agent/workflow name, so a\ncomponent never repeats it. `asI18n` is the i18n brand (see **pikku-i18n**).\n\n## Resolving the server URL\n\nEvery client (`createPikku`, realtime, the auth client) resolves its base\nthrough one shared helper in `src/lib/env.ts`. Write this once:\n\n```ts\n// Endpoints come from env, never hardcoded.\nexport function apiUrl(): string {\n // SSR: the client hooks only run in the browser, so a placeholder is fine.\n if (import.meta.env.SSR) {\n return import.meta.env.VITE_API_URL ?? '/__api'\n }\n return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`\n}\n```\n\n**Never fall back to `http://localhost:3000`.** `import.meta.env.VITE_API_URL`\nis substituted by Vite at *build* time, so any deploy that supplies the URL as\na *runtime* env var or platform binding leaves it `undefined` in the shipped\nbundle — the fallback is then the only branch that ever runs in the browser. A\nlocalhost fallback means every request from a deployed app goes to the user's\nown machine. `origin + '/api'` is same-origin, needs no build-time knowledge of\nthe domain, and is correct wherever the app is served from.\n\nFor local dev, set `VITE_API_URL`, or proxy `/api` → your backend in\n`vite.config.ts` under `server.proxy`. One `/api` entry also covers\n`/api/auth/*`; only add more entries for root-level routes outside `/api`.\n\n## Setup at the app root\n\n```tsx\nimport { createPikku, PikkuProvider } from '@pikku/react'\nimport { PikkuFetch } from './pikku/pikku-fetch.gen'\nimport { PikkuRPC } from './pikku/pikku-rpc.gen'\nimport { apiUrl } from './lib/env'\n\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n serverUrl: apiUrl(),\n})\n\ncreateRoot(document.getElementById('root')!).render(\n <PikkuProvider pikku={pikku}>\n <App />\n </PikkuProvider>\n)\n```\n\nIf the project also exposes realtime events (see **pikku-realtime**), pass\nthe `PikkuRealtime` class as the third argument and the instance gets a\n`realtime` field too:\n\n```tsx\nimport { PikkuRealtime } from './pikku/realtime.gen'\n\nconst pikku = createPikku(PikkuFetch, PikkuRPC, PikkuRealtime, {\n serverUrl: apiUrl(),\n})\n// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch\n// (server URL + auth configured once).\n```\n\nThe generated classes come from your `pikku.config.json`:\n\n| config field | generated file |\n| ---------------------------- | ----------------------------------------------------- |\n| `clientFiles.fetchFile` | typed HTTP client (`PikkuFetch` class) |\n| `clientFiles.rpcWiringsFile` | RPC client (`PikkuRPC` class) calling all exposed fns |\n| `clientFiles.realtimeFile` | `PikkuRealtime` (websocket events + SSE + channels) |\n\nIf a file isn't being generated, that field is missing from the config —\nadd it and re-run `pikku all`.\n\n`createPikku(...)` accepts the same `CorePikkuFetchOptions` as `PikkuFetch`\nplus `serverUrl`. Auth headers, request interceptors, etc. are configured\non the fetch instance — RPC and realtime inherit them automatically.\n\n## Calling an RPC directly (no React Query)\n\nInside a component:\n\n```tsx\nimport { usePikkuRPC } from '@pikku/react'\n\nfunction Logout() {\n const rpc = usePikkuRPC()\n return <button onClick={() => rpc.invoke('logoutUser', {})}>Sign out</button>\n}\n```\n\n`rpc.invoke(name, data)` is typed against `FlattenedRPCMap` — `name` must\nbe an exposed function id, `data` matches the input schema, return value\nmatches the output schema.\n\nYou also have `rpc.<funcName>(data)` if the generated RPC client builds\ndirect methods (project-dependent).\n\n## Calling fetch directly\n\n```tsx\nconst fetch = usePikkuFetch()\nconst data = await fetch.get('/some-rest-route', { searchParams: {...} })\n```\n\nUse this only when the function is wired via HTTP (REST shape) and you\nneed a path-style call. For RPC calls, `usePikkuRPC()` is cleaner.\n\n## Realtime subscriptions\n\nIf you wired a `PikkuRealtime` class into `createPikku`, use\n`usePikkuRealtime()` to grab the shared instance:\n\n```tsx\nimport { usePikkuRealtime } from '@pikku/react'\nimport type { PikkuRealtime } from './pikku/realtime.gen'\n\nfunction TodoList() {\n const realtime = usePikkuRealtime<PikkuRealtime>()\n useEffect(() => {\n return realtime.subscribe('todo-created', ({ todo }) => {\n /* ... */\n })\n }, [realtime])\n // ...\n}\n```\n\nThe hook throws if no `PikkuRealtime` was wired — that's how you know to\nadd it to `createPikku(...)`. Full event-hub setup, publishing, and SSE\nhelpers live in **pikku-realtime**.\n\n## When to reach for what\n\n| Need | Use |\n| ----------------------------------- | --------------------------------------------- |\n| Render data, dedupe + cache | **usePikkuQuery** (react-query) |\n| Trigger a write, wait for result | **usePikkuMutation** (react-query) |\n| Paginate | **usePikkuInfiniteQuery** (react-query) |\n| One-off call from an event handler | `usePikkuRPC()` direct |\n| Hit a REST endpoint (not RPC) | `usePikkuFetch()` |\n| Run one named workflow | `usePikkuWorkflow('name')` → `.start/.run/.status` |\n| Talk to one named AI agent | `usePikkuAgent('name')` → `.run/.stream/.approve` |\n| Longer-running workflow UX | **pikku-workflows-client** |\n| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |\n\nThe first three live in your generated `api.gen.ts` (see the\n**pikku-react-query** skill). This skill covers the rest.\n\n`usePikkuAgent` and `usePikkuWorkflow` bind the name once and hand back the\ncall methods with it already applied:\n\n```tsx\nconst agent = usePikkuAgent('todo-agent')\nconst { text } = await agent.run({ message, threadId })\n\nconst workflow = usePikkuWorkflow('onboardUser')\nconst { runId } = await workflow.start({ email })\nconst state = await workflow.status(runId)\n```\n\n## Authentication\n\nAuth is handled at the `PikkuFetch` layer, and `createPikku`'s options object\n*is* `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a\n`fetchOptions` key:\n\n```tsx\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n serverUrl: apiUrl(),\n credentials: 'include', // cookie sessions\n authHeaders: { jwt: token }, // or { apiKey }\n transformDate: true,\n})\n```\n\nThere is no request-interceptor hook. For a token that changes after startup,\ncall the setter on the shared instance — RPC and realtime pick it up because\nthey hold the same fetch:\n\n```tsx\npikku.fetch.setAuthorizationJWT(token) // null clears it\npikku.fetch.setAPIKey(key)\npikku.fetch.setHeader('x-tenant', tenantId)\n```\n\n`authHeaders.jwt` becomes `Authorization: Bearer …` and `authHeaders.apiKey`\nbecomes `X-API-KEY`; setting a JWT takes precedence over an API key.\n\n## What NOT to do\n\n- Don't instantiate `PikkuFetch`/`PikkuRPC` inside a component — `createPikku`\n goes once at the app root, the instance flows through Context.\n- Don't call `usePikkuRPC()` outside a `<PikkuProvider>` — it throws.\n- Don't write a custom RPC client. The generated one already covers every\n exposed function with full types.\n- Don't hardcode user-facing strings. Every display string goes through an\n i18n token — see **pikku-i18n** for the setup (it's English-only by default).\n", "pikku-react-query/SKILL.md": "---\nname: pikku-react-query\ndescription: 'Use the Pikku auto-generated React Query hooks (`usePikkuQuery`, `usePikkuMutation`, `usePikkuInfiniteQuery`) to call backend RPC functions from a React frontend with full type safety. TRIGGER when: writing React components that need to call a Pikku function, fetch data, mutate data, or paginate; user mentions React Query, useQuery, useMutation, or building a frontend that talks to a Pikku backend. DO NOT TRIGGER when: working on the backend (use pikku-rpc / pikku-feature) or wiring a non-React frontend.'\ninstallGroups: [core]\n---\n\n# Pikku React Query Hooks\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nPikku generates a typed React Query layer from your backend `expose: true`\nfunctions. You don't write `useQuery`/`useMutation` against `fetch`\nyourself — you call hooks named after RPCs and get full type inference for\ninput + output.\n\n## Discover what's available on the client\n\nBefore writing a hook, get the full client surface in one call:\n\n```bash\nyarn pikku meta clients --json\n```\n\nReturns RPCs, workflows, and channels with descriptions and type names:\n\n```json\n{\n \"rpcs\": [\n { \"name\": \"createTodo\", \"description\": \"Create a todo\",\n \"readonly\": false, \"input\": \"CreateTodoInput\", \"output\": \"CreateTodoOutput\" },\n { \"name\": \"listTodos\", \"description\": \"List all todos\",\n \"readonly\": true, \"input\": null, \"output\": \"ListTodosOutput\" }\n ],\n \"workflows\": [...],\n \"channels\": [...]\n}\n```\n\nThe `name` is the RPC identifier; pass it to the hooks below. Input/output\nshapes are inferred automatically — the hook is typed against\n`FlattenedRPCMap[name]['input' | 'output']`. Use `description` to pick the\nright RPC; use `readonly` to choose `usePikkuQuery` vs `usePikkuMutation`.\n\n## Setup (once per app)\n\nIn your app entry (e.g. `main.tsx`):\n\n```tsx\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { PikkuProvider, createPikku } from '@pikku/react'\nimport { PikkuFetch } from './pikku/pikku-fetch.gen'\nimport { PikkuRPC } from './pikku/pikku-rpc.gen'\n\nimport { apiUrl } from './lib/env'\n\nconst queryClient = new QueryClient()\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n serverUrl: apiUrl(),\n})\n\n<QueryClientProvider client={queryClient}>\n <PikkuProvider pikku={pikku}>\n <App />\n </PikkuProvider>\n</QueryClientProvider>\n```\n\nThe two generated files come from `pikku.config.json`'s\n`clientFiles.fetchFile` and `clientFiles.rpcWiringsFile`. Hooks live in\nthe file at `clientFiles.reactQueryFile` (typically `api.gen.ts`).\n\n`apiUrl()` is the shared server-URL helper — see **pikku-react**. Never\ninline `?? 'http://localhost:3000'`: a deploy that supplies the URL as a\nruntime binding leaves `import.meta.env.VITE_API_URL` undefined in the\nbundle, so the fallback is the branch that actually runs.\n\n## TanStack Start (SSR)\n\nUnder Start the provider mounts in `routes/__root.tsx` rather than\n`main.tsx`, and the same module is evaluated on the server. Three things\ndiffer:\n\n1. **`apiUrl()` must have an SSR branch.** `window` is undefined during\n render; return the build-time var or a placeholder (the client hooks\n only fire in the browser).\n2. **Build auth clients lazily.** Better Auth validates its baseURL with\n `new URL(...)` at construction, so a module-scope `createAuthClient`\n crashes SSR on the placeholder. Memoize it behind a getter:\n\n ```ts\n let _authClient: ReturnType<typeof createAuthClient> | undefined\n export const authClient = () =>\n (_authClient ??= createAuthClient({ baseURL: `${apiUrl()}/auth` }))\n ```\n\n3. **The auth baseURL needs the `/auth` suffix.** Better Auth only\n appends its default `/api/auth` when the baseURL carries no path.\n `apiUrl()` already ends in `/api`, so a bare `apiUrl()` leaves the\n client calling `/api/get-session` and 404ing.\n\nServer functions that need typed RPC access use the generated shim:\n\n```bash\npikku tanstack-start # emits the makeApi server-function shim\n```\n\n## The hooks\n\nAll hooks are imported from your generated `api.gen.ts`:\n\n```tsx\nimport {\n usePikkuQuery,\n usePikkuMutation,\n usePikkuInfiniteQuery,\n} from './pikku/api.gen'\n```\n\n### `usePikkuQuery(name, data, options?)`\n\nFor RPCs that **read** data. Cacheable. The hook is typed against the RPC's\ninput + output.\n\n```tsx\nexport function TodoList() {\n const { data, isLoading, error } = usePikkuQuery('listTodos', {})\n\n if (isLoading) return <p>Loading…</p>\n if (error) return <p>{error.message}</p>\n return (\n <ul>\n {data?.todos.map((t) => (\n <li key={t.id}>{t.title}</li>\n ))}\n </ul>\n )\n}\n```\n\nThe query key is `[name, data]` automatically — no manual key wrangling.\nPass standard `useQuery` options through (`staleTime`, `enabled`, etc.).\n\n### `usePikkuMutation(name, options?)`\n\nFor RPCs that **write**. Returns a React Query mutation object.\n\n```tsx\nexport function CreateTodoForm() {\n const queryClient = useQueryClient()\n const mutation = usePikkuMutation('createTodo', {\n onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listTodos'] }),\n })\n\n const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n e.preventDefault()\n const title = (\n e.currentTarget.elements.namedItem('title') as HTMLInputElement\n ).value\n mutation.mutate({ title })\n }\n\n return (\n <form onSubmit={onSubmit}>\n <input name=\"title\" />\n <button type=\"submit\" disabled={mutation.isPending}>\n {mutation.isPending ? 'Adding…' : 'Add'}\n </button>\n </form>\n )\n}\n```\n\nThe input passed to `mutation.mutate(...)` is type-checked against the RPC's\ninput schema. After success, **invalidate** any list/get queries that should\nrefetch.\n\n### `usePikkuInfiniteQuery(name, data, options?)`\n\nThe hook's `name` parameter is narrowed to RPCs whose **output** has a\n`nextCursor?: string | null` field, so calling it with anything else is a type\nerror rather than a missing hook. That output field is read after each page and\nsent back as the **input** field `cursor` — which is why the `data` you pass is\n`Omit<input, 'cursor'>`: the hook owns that key.\n\n```tsx\nconst { data, fetchNextPage, hasNextPage, isFetchingNextPage } =\n usePikkuInfiniteQuery('listTodos', { limit: 20 })\n\nconst todos = data?.pages.flatMap((p) => p.todos) ?? []\n```\n\nSo the backend contract is a pair: output `nextCursor`, input `cursor`. An RPC\nmissing either one paginates with `usePikkuQuery` and manual cursor state\ninstead.\n\n## Workflow hooks\n\nWhen the project defines any workflow, the same file also gains\n`useStartWorkflow(name)` (mutation → `{ runId }`), `useRunWorkflow(name)`\n(mutation → the workflow's output) and `useWorkflowStatus(name, runId?)` (query,\ndisabled until `runId` is set). See the **pikku-workflows-client** skill.\n\n## Calling RPCs without React Query\n\nFor one-off calls (event handlers outside of state, side effects), use\n`usePikkuRPC()` from `@pikku/react`:\n\n```tsx\nconst rpc = usePikkuRPC()\nconst handleClick = async () => {\n const result = await rpc.invoke('createTodo', { title: 'inline' })\n}\n```\n\nBut prefer the React Query hooks for anything that touches render state —\ncaching, retries, dedup, and dev-tools come for free.\n\n## Common patterns\n\n- **Optimistic updates**: pass `onMutate` to `usePikkuMutation` to update\n the cache before the server responds. Standard React Query pattern;\n Pikku doesn't add anything special.\n- **Conditional fetching**: pass `enabled: !!someValue` to skip a query\n until you have the input.\n- **Refetch on focus**: enabled by default in React Query; disable with\n `refetchOnWindowFocus: false` in options.\n\n## What NOT to do\n\n- Don't import the RPC client directly and call it inside `useEffect` —\n use the hooks. They handle dedup, caching, and unmount safely.\n- Don't hand-write `useQuery({ queryKey: ['listTodos'], queryFn: ... })`\n — `usePikkuQuery('listTodos', {})` does it correctly with one line.\n- Don't construct hook names dynamically. Hook names = RPC names known at\n generation time.\n- Don't bypass the type system with `as any` — if a hook's types don't\n match what you expect, the backend's input/output schemas are wrong;\n fix those first.\n", "pikku-realtime/references/other-routes.md": "# Subscribing to other SSE / WebSocket routes\n\nThe same `PikkuRealtime` client also handles generic SSE + channel routes (not just\n`/events` topics). Use the path; the base URL is inherited from `PikkuFetch`.\n\n```ts\n// Any `sse: true` HTTP route\nconst sub = realtime.subscribeToSSE<{ progress: number }>(\n `/workflow-run/${runId}/stream`,\n (event) => setProgress(event.progress)\n)\n// later: sub.close()\n\n// Any wireChannel — open a raw socket, wrap in PikkuWebSocket for typed I/O\nconst ws = realtime.connectToChannel('/ws/kanban')\nconst typed = new PikkuWebSocket<'kanban-live'>(ws)\ntyped.getRoute('command').subscribe('message', (data) => {\n /* ... */\n})\n```\n\nDiscover what's available with `pikku meta clients --json` — `channels` and any HTTP\n`sse: true` routes are listed there.\n", "pikku-realtime/SKILL.md": "---\nname: pikku-realtime\ndescription: 'Use Pikku''s realtime feature — typed pub/sub events over WebSocket (multi-topic) or SSE (single-topic, auto-cleanup). Covers declaring EventHubTopics, scaffolding the /events channel, the auto-generated `PikkuRealtime` client, and publishing events from a function. TRIGGER when: the user asks for realtime updates, pub/sub, push notifications, server-sent events, websocket events, eventhub, or \"live\" data on the frontend. DO NOT TRIGGER when: the user wants RPC-style request/response (use pikku-rpc / pikku-react-query) or a custom one-off WebSocket channel (use pikku-websocket).'\ninstallGroups: [core]\n---\n\n# Pikku Realtime\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nMost realtime UI is just typed pub/sub: a server pushes `todo-created`, the client\nrenders it. Pikku ships exactly that, two ways — both use the same `EventHubService`\nand the same publish call, so choose by transport, not by code shape:\n\n- **WebSocket** at `/events` — one connection, many topic subscriptions.\n- **SSE** at `GET /events/:topic` — one connection per topic, auto-cleanup on\n disconnect. Good when WebSocket is blocked or for trivially streaming one topic.\n\n## 1. Declare your topics\n\nIn your project's types file (e.g. `types/eventhub-topics.d.ts`):\n\n```ts\nimport type { Todo } from '../src/schemas.js'\n\nexport type EventHubTopics = {\n 'todo-created': { todo: Todo }\n 'todo-updated': { todo: Todo }\n 'todo-deleted': { todoId: string }\n}\n```\n\nReference it in `application-types.d.ts` and instantiate it in `services.ts`:\n\n```ts\n// application-types.d.ts\nimport type { EventHubService } from '@pikku/core/channel'\nimport type { EventHubTopics } from './eventhub-topics.js'\n\nexport interface SingletonServices extends CoreSingletonServices<Config> {\n // `CoreSingletonServices` declares eventHub optional; re-declare it required\n // so functions can use it without a `if (eventHub)` guard on every publish.\n eventHub: EventHubService<EventHubTopics>\n}\n\n// services.ts\nimport { LocalEventHubService } from '@pikku/core/channel'\nconst eventHub = new LocalEventHubService<EventHubTopics>()\n```\n\nFor multi-instance deployments use `CloudflareEventHubService` /\n`LambdaEventHubService` / `UWSEventHubService` instead — same interface.\n\nIf a deployment genuinely has no eventHub, that belongs in `services.ts` (don't\ncreate the service there), not as an optional type every function has to guard —\nsee `pikku-services`.\n\n## 2. Enable the server side\n\n```bash\nyarn pikku enable events # auth required by default\nyarn pikku enable events --noAuth # public events\n```\n\nThis sets `scaffold.events` in `pikku.config.json`. The next `pikku all` generates\n`events.gen.ts` in your scaffold dir, wiring (using whatever `eventHub` is in your\nsingletons — you write neither by hand):\n\n- A WebSocket channel at `/events` handling `{action: 'subscribe' | 'unsubscribe', topic}` messages.\n- An SSE handler at `GET /events/:topic`.\n\n## 3. Generate the typed client\n\nAdd to `pikku.config.json`:\n\n```jsonc\n{\n \"clientFiles\": {\n \"realtimeFile\": \"packages/sdk/src/pikku/realtime.gen.ts\",\n // Optional: full type inference for subscribe/unsubscribe\n \"realtimeEventHubTopicsImport\": \"../../../functions/types/eventhub-topics.js#EventHubTopics\",\n },\n}\n```\n\nRun `pikku all` (or `pikku realtime` to regenerate just this file). Everything is\non one class — both transports are methods, so switching from WebSocket to SSE is\na one-word change, not a different import:\n\n```ts\nexport class PikkuRealtime {\n constructor(options?: { reconnect?: boolean; reconnectDelayMs?: number; reconnectMaxDelayMs?: number })\n setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor\n\n // WebSocket at /events — many topics on one connection\n subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void\n unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: (data: EventHubTopics[K]) => void): void\n\n // SSE at GET /events/:topic — one EventSource per topic\n subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }\n\n // generic escape hatches — see references/other-routes.md\n subscribeToSSE<T>(path: string, handler: (data: T) => void): { close: () => void }\n connectToChannel(channelRoute: string, protocols?: string | string[]): WebSocket\n\n close(): void\n}\n```\n\nWithout `realtimeEventHubTopicsImport`, the client falls back to\n`Record<string, unknown>` — usable but untyped. Set the import for full typed\nsubscribe/unsubscribe.\n\n## 4. Publish events from a function\n\nThe `/events` channel listens for client subscriptions; the eventHub fans out\npublishes:\n\n```ts\npublish(topic, channelId: string | null, data, isBinary?)\n```\n\nThe middle argument is the channel to **skip**, not the one to send to — pass\n`null` to reach every subscriber, or the current `channel.channelId` when the\noriginating connection has already applied the change locally and would otherwise\nrender it twice.\n\nEnvelope the payload as `{ topic, data }`: the generated client dispatches on the\n`topic` field, so a bare payload arrives but no handler fires.\n\n```ts\nimport { pikkuFunc } from '#pikku'\n\nexport const createTodo = pikkuFunc({\n input: CreateTodoInput,\n output: CreateTodoOutput,\n func: async ({ kysely, eventHub }, data) => {\n const todo = await kysely\n .insertInto('todos').values(data).returningAll()\n .executeTakeFirstOrThrow()\n\n await eventHub.publish('todo-created', null, {\n topic: 'todo-created',\n data: { todo },\n })\n return { id: todo.id }\n },\n})\n```\n\nA thin helper removes the duplication:\n\n```ts\nasync function publishEvent<K extends keyof EventHubTopics>(\n hub: EventHubService<EventHubTopics>, topic: K, data: EventHubTopics[K]\n) {\n return hub.publish(topic, null, { topic, data })\n}\n// usage: await publishEvent(eventHub, 'todo-created', { todo })\n```\n\n## 5. Wire it up — share fetch with PikkuRPC\n\n`PikkuRealtime` mirrors `PikkuRPC`: it wraps the same `PikkuFetch`, so server URL +\nauth are configured **once** and shared across HTTP, RPC, and realtime transports.\n\n```tsx\nimport { createPikku, PikkuProvider } from '@pikku/react'\nimport { PikkuFetch } from './pikku/pikku-fetch.gen'\nimport { PikkuRPC } from './pikku/pikku-rpc.gen'\nimport { PikkuRealtime } from './pikku/realtime.gen'\n\nconst pikku = createPikku(\n PikkuFetch,\n PikkuRPC,\n PikkuRealtime, // pass the realtime class as the third arg\n { serverUrl: apiUrl() } // shared env helper — see pikku-react\n)\n// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.\n\ncreateRoot(document.getElementById('root')!).render(\n <PikkuProvider pikku={pikku}><App /></PikkuProvider>\n)\n```\n\nOr wire manually:\n\n```ts\nconst realtime = new PikkuRealtime()\nrealtime.setPikkuFetch(pikku.fetch) // inherits serverUrl + auth\n```\n\n## 6. Subscribe from React\n\nSubscribe inside `useEffect` (never the render path, or you create a subscription\nper render). `subscribe` returns an unsubscribe function; SSE's `subscribeToTopic`\nreturns a handle with `close()`:\n\n```tsx\nimport { useEffect, useState } from 'react'\n\nfunction TodoList() {\n const { realtime } = usePikku() // a hook over your context\n const [todos, setTodos] = useState<Todo[]>([])\n\n useEffect(() => {\n // WebSocket multi-topic:\n const off = realtime.subscribe('todo-created', ({ todo }) =>\n setTodos((prev) => [...prev, todo]))\n return off\n\n // Single-topic SSE (auto-cleanup on close) instead:\n // const sub = realtime.subscribeToTopic('todo-created', ({ todo }) =>\n // setTodos((prev) => [...prev, todo]))\n // return () => sub.close()\n }, [realtime])\n\n return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>\n}\n```\n\n## Other SSE / WebSocket routes\n\nThe same client also subscribes to generic `sse: true` routes and raw `wireChannel`\nsockets (`subscribeToSSE`, `connectToChannel`). See\n[references/other-routes.md](references/other-routes.md).\n\n## When to pick which transport\n\n| Need | Use |\n| ------------------------------------------ | ----------------------------- |\n| Many topics in one connection | `realtime.subscribe` |\n| Single live stream, simple cleanup | `realtime.subscribeToTopic` |\n| Bidirectional (client also sends messages) | `realtime.subscribe` |\n| WebSockets blocked by infra | `realtime.subscribeToTopic` |\n\nBoth auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes\nall topics for the dead channel id). Don't write manual cleanup unless you're\nunsubscribing partway through a session.\n\n## What NOT to do\n\n- Don't call `eventHub.publish(topic, ..., rawData)` without the `{topic, data}`\n envelope — clients use `topic` to dispatch handlers.\n- Don't create your own `/events` channel by hand — `pikku enable events` already\n does it correctly with disconnect cleanup.\n- Don't subscribe inside the render path — use `useEffect`.\n- Don't subscribe to topics that don't exist in `EventHubTopics`. The generated\n client's types prevent it; if you reach for `as any` to subscribe to a string,\n declare the topic first.\n", "pikku-redis/SKILL.md": "---\nname: pikku-redis\ndescription: >-\n Use when setting up Redis-backed services in a Pikku app. Covers channel stores, workflow\n services, secret services, event hubs, agent runs, and deployment services backed by Redis.\n TRIGGER when: code uses RedisChannelStore, RedisWorkflowService, RedisSecretService, or user\n asks about Redis setup with Pikku. DO NOT TRIGGER when: user asks about BullMQ queues (use\n pikku-queue) or SQL databases (use pikku-kysely).\n---\n\n# Pikku Redis\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/redis` provides Redis-backed implementations of Pikku's core service interfaces using [ioredis](https://github.com/redis/ioredis).\n\n## Installation\n\n```bash\nyarn add @pikku/redis\n```\n\n## API Reference\n\n### Available Services\n\nAll services accept a Redis connection (ioredis `Redis` instance, `RedisOptions`, or connection string) in their constructor.\n\n| Service | Interface | Purpose |\n| ------------------------- | ---------------------- | ---------------------------------------------- |\n| `RedisChannelStore` | `ChannelStore` | WebSocket channel state persistence |\n| `RedisEventHubStore` | `EventHubStore` | Event hub state persistence |\n| `RedisWorkflowService` | `PikkuWorkflowService` | Workflow definition storage |\n| `RedisWorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |\n| `RedisDeploymentService` | `DeploymentService` | Deployment state management |\n| `RedisAgentRunService` | `AgentRunService` | Agent execution tracking |\n| `RedisSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |\n| `RedisSessionStore` | `SessionStore` | Persisted user sessions |\n\n### Secret Service\n\nEnvelope encryption: `key` derives the KEK that wraps each secret's own DEK.\nKeeping `previousKey` set is what makes `rotateKEK()` possible — it re-wraps\nevery secret onto the current key and returns the new version.\n\n```typescript\nimport { RedisSecretService } from '@pikku/redis'\n\nconst secrets = new RedisSecretService(\n connectionOrConfig: Redis | RedisOptions | string,\n config: {\n key: string // the KEK passphrase\n keyVersion?: number // defaults to 1\n previousKey?: string // required to rotate\n keyPrefix?: string // namespaces the redis keys\n }\n)\n\nawait secrets.getSecret<T = string>(key: string): Promise<T>\nawait secrets.getSecrets<T>(keys: (keyof T & string)[]): Promise<Partial<T>>\nawait secrets.hasSecret(key: string): Promise<boolean>\nawait secrets.setSecret(key: string, value: unknown): Promise<void>\nawait secrets.deleteSecret(key: string): Promise<void>\nawait secrets.rotateKEK(): Promise<number>\nawait secrets.close(): Promise<void>\n```\n\n## Usage Patterns\n\n### Full Setup\n\n```typescript\nimport {\n RedisChannelStore,\n RedisWorkflowService,\n RedisSecretService,\n} from '@pikku/redis'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new PinoLogger()\n\n const channelStore = new RedisChannelStore(config.redisUrl)\n const workflowService = new RedisWorkflowService(config.redisUrl)\n\n const secrets = new RedisSecretService(config.redisUrl, {\n key: config.kekPassphrase,\n })\n\n return { config, logger, channelStore, workflowService, secrets }\n})\n```\n", "pikku-rpc/SKILL.md": "---\nname: pikku-rpc\ndescription: >-\n Use when making internal function-to-function calls within a Pikku app, composing functions, or\n exposing RPC endpoints. Covers rpc.invoke, rpc.remote, rpc.exposed, and generated RPC client.\n TRIGGER when: code uses wire.rpc or expose: true, user asks about calling one Pikku function\n from another, function composition, or RPC endpoints. DO NOT TRIGGER when: user asks about HTTP\n routes (use pikku-http) or addon cross-package calls (use pikku-addon).\ninstallGroups: [core]\n---\n\n# Pikku RPC Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nCall Pikku functions from other Pikku functions internally with full type safety. Use RPC to compose business logic without importing functions directly.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and which could be called via RPC\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### RPC Methods (on `wire.rpc`)\n\n| Method | Purpose |\n| -------------------------------- | ----------------------------------------- |\n| `rpc.invoke(name, data)` | Internal call to any wired function |\n| `rpc.remote(name, data)` | Remote call via DeploymentService |\n| `rpc.exposed(name, data)` | Call functions marked with `expose: true` |\n| `rpc.startWorkflow(name, input)` | Start a workflow (see `pikku-workflow`) |\n| `rpc.agent.run/stream(...)` | Run an AI agent (see `pikku-ai-agent`) |\n| `rpc.agent.resume/approve(...)` | Answer a tool-approval interrupt |\n| `rpc.agent.interrupt(runId)` | Stop an in-flight run |\n\n`rpc.invoke`, `rpc.remote` and `rpc.startWorkflow` are typed off the generated\nRPC map, so the name and the payload are checked. `rpc.exposed` is deliberately\n`(name: string, data: any) => Promise<any>` — it exists to dispatch a name that\narrived from outside, which by definition cannot be checked at compile time.\nReach for `rpc.invoke` whenever the name is known statically.\n\n`rpc` also carries `depth` (how deep the current RPC chain is, so runaway\nrecursion is visible) and `global`.\n\n### Exposed Functions\n\nMark a function as externally callable via RPC:\n\n```typescript\nconst greet = pikkuSessionlessFunc({\n title: 'Greet',\n expose: true, // ← callable via rpc.exposed()\n func: async ({}, { name }) => {\n return { message: `Hello, ${name}!` }\n },\n})\n```\n\n### HTTP RPC Endpoint\n\nThe `POST /rpc/:rpcName` endpoint that dispatches every `expose: true` function\nis **generated, not hand-written**. Turn it on and let codegen own it:\n\n```bash\npikku enable rpc # sets scaffold.rpc = true (auth required)\npikku enable rpc --noAuth # sets scaffold.rpc = { auth: false } (public)\n```\n\nThis writes `rpc-public.gen.ts` with an `rpcCaller` function and its `wireHTTP`\ncall already wired. Do not write that wiring yourself — a hand-rolled copy\ncollides with the generated route on the same path.\n\n## Usage Patterns\n\n### Internal Function Composition\n\n```typescript\nconst calculateTax = pikkuSessionlessFunc({\n title: 'Calculate Tax',\n func: async ({}, { amount, rate }) => {\n return { tax: amount * rate }\n },\n})\n\nconst processOrder = pikkuFunc({\n title: 'Process Order',\n func: async ({ db }, { orderId }, { rpc }) => {\n const order = await db.getOrder(orderId)\n\n // Call another pikku function internally — fully typed\n const { tax } = await rpc.invoke('calculateTax', {\n amount: order.total,\n rate: 0.08,\n })\n\n return { orderId, total: order.total + tax }\n },\n})\n```\n\n### When to Use RPC vs Direct Imports\n\n| Approach | Use When |\n| -------------- | -------------------------------------------------------------------------------------------- |\n| `rpc.invoke()` | Cross-domain calls, maintaining separation of concerns, function may be in different package |\n| Direct import | Same module, tightly coupled logic, performance critical |\n\nRPC calls go through Pikku's middleware and permission pipeline. Direct imports skip them.\n\n### Generated RPC Client\n\nAfter `npx pikku all`:\n\n```typescript\nimport { pikkuRPC } from '#pikku/pikku-rpc.gen.js'\n\npikkuRPC.setServerUrl('http://localhost:4002')\n\nconst result = await pikkuRPC.invoke('calculateTax', {\n amount: 100,\n rate: 0.08,\n})\n\npikkuRPC.setAuthorizationJWT(token)\n```\n\n## Complete Example\n\n```typescript\n// functions/billing.functions.ts\nexport const calculateTax = pikkuSessionlessFunc({\n title: 'Calculate Tax',\n func: async ({}, { amount, region }) => {\n const rates = { US: 0.08, EU: 0.2, UK: 0.2 }\n return { tax: amount * (rates[region] || 0) }\n },\n})\n\nexport const calculateShipping = pikkuSessionlessFunc({\n title: 'Calculate Shipping',\n func: async ({}, { weight, region }) => {\n const base = region === 'US' ? 5 : 15\n return { shipping: base + weight * 0.5 }\n },\n})\n\n// functions/orders.functions.ts\nexport const processOrder = pikkuFunc({\n title: 'Process Order',\n func: async ({ db }, { orderId }, { rpc }) => {\n const order = await db.getOrder(orderId)\n\n const { tax } = await rpc.invoke('calculateTax', {\n amount: order.total,\n region: order.region,\n })\n\n const { shipping } = await rpc.invoke('calculateShipping', {\n weight: order.totalWeight,\n region: order.region,\n })\n\n const finalTotal = order.total + tax + shipping\n await db.updateOrder(orderId, { tax, shipping, finalTotal })\n\n return { orderId, total: finalTotal, tax, shipping }\n },\n})\n```\n", "pikku-rtl/SKILL.md": "---\nname: pikku-rtl\ndescription: 'Make a Pikku frontend work in both English (LTR) and Arabic / right-to-left languages. Direction is derived from the active locale, applied once at the document root, and the layout mirrors itself — but only if styling is written flow-relative (margin-inline-start, text-align: start, Mantine ms/me) instead of left/right. TRIGGER when: adding Arabic (or Hebrew/Farsi/Urdu), asked to \"support RTL / right-to-left / bidi / mirror the layout\", or writing layout styles in an app that may run RTL. Builds on pikku-i18n (an RTL language is just another locale file). DO NOT TRIGGER for backend functions or for LTR-only copy changes.'\ninstallGroups: [core]\n---\n\n# Pikku RTL (Arabic + English)\n\nThis skill sits **on top of** `pikku-i18n`. That skill compiles a locale's\nmessages into typed `m.*()` functions; this one adds the second axis: a locale\nalso has a **direction**. Arabic is not special-cased — it is just another\n`messages/ar.json` listed in `project.inlang/settings.json`, plus the document\nbeing told it is `rtl`.\n\n## The one idea\n\nSet `dir` **once at the document root** from the active locale, then let the\nbrowser and Mantine mirror everything — _provided_ every custom style is written\n**flow-relative** (start/end), never **physical** (left/right). Get those two\nthings right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component\n*layout* code — directional icons still need one manual step, covered below.\n\n## Agent Operating Procedure\n\n1. **Messages first.** Every visible string is already an `m.*()` message via\n `pikku-i18n`. Arabic copy goes in `messages/ar.json`, mirroring `en.json`'s\n keys with the `{param}` names kept identical.\n2. **Add the direction helper** to the i18n config (one home for locale→dir):\n ```ts\n const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])\n export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {\n return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'\n }\n ```\n (The bundled templates already ship this helper — use it, don't reinvent it.)\n3. **Apply `dir` + `lang` at the root**, once, from the active locale — pick the\n recipe for your framework below.\n4. **Write every layout style flow-relative.** This is the part that actually\n makes mirroring work; see the rules. When editing existing UI to be\n RTL-ready, the job is mostly a search-and-replace of physical properties.\n5. **Flip directional icons** (chevrons, back/forward arrows) — the one thing\n logical properties can't do for you.\n6. Validate with the app's `tsc`, then load `?i18n-debug` / set `dir` and\n eyeball that the layout mirrors and nothing is stuck on the wrong edge.\n\n## Flow-relative, not physical — the rules that make it mirror\n\nUse the **inline-axis logical** property; never the physical one:\n\n| Don't (physical) | Do (flow-relative) |\n| ---------------------------- | -------------------------------------------- |\n| `margin-left` / `marginLeft` | `margin-inline-start` / `marginInlineStart` |\n| `margin-right` | `margin-inline-end` / `marginInlineEnd` |\n| `padding-left/right` | `padding-inline-start/end` |\n| `left: 0` / `right: 0` | `inset-inline-start: 0` / `inset-inline-end` |\n| `text-align: left/right` | `text-align: start / end` |\n| `border-top-left-radius` | `border-start-start-radius` |\n| `float: left/right` | `float: inline-start / inline-end` |\n\nIn **Mantine**, use the logical style props — they emit the logical CSS above:\n\n| Don't | Do |\n| ----------- | ----------- |\n| `ml` / `mr` | `ms` / `me` |\n| `pl` / `pr` | `ps` / `pe` |\n\nMantine's own components already use logical properties internally, so once the\ndirection is set they mirror automatically — you only have to be disciplined in\n**your** styles.\n\n**Leave flexbox and grid alone.** `display:flex` already follows `dir`:\n`justify-content: flex-start` resolves to the right edge under RTL on its own.\nNever \"fix\" RTL by swapping to `flex-direction: row-reverse` or reordering DOM —\nthat double-flips and breaks the moment direction changes. The DOM order is\nlogical order; let `dir` handle the visual order.\n\n## Applying direction at the root\n\n### Mantine app (e.g. environment-template)\n\nMantine ships first-class RTL: wrap the tree in `DirectionProvider` and set the\nmatching `dir` on `<html>`.\n\n```tsx\nimport { DirectionProvider, MantineProvider } from '@mantine/core'\nimport { detectLocale, localeDir } from './i18n/config'\n\nconst locale =\n typeof window !== 'undefined' ? detectLocale(window.location.pathname) : 'en'\nconst dir = localeDir(locale)\n\nif (typeof document !== 'undefined') {\n document.documentElement.lang = locale\n document.documentElement.dir = dir // Mantine + browser read this\n}\n\nroot.render(\n <DirectionProvider initialDirection={dir}>\n <MantineProvider theme={theme} defaultColorScheme=\"dark\">\n {/* …app… */}\n </MantineProvider>\n </DirectionProvider>\n)\n```\n\nTo flip direction live (a language switcher) call\n`document.documentElement.setAttribute('dir', localeDir(next))` and Mantine's\n`useDirection().setDirection(dir)`; both read the same value.\n\n### Plain Vite SPA (kanban, test-harness vite-spa)\n\nNo Mantine — just put `dir`/`lang` on `<html>` at bootstrap, after the locale is\ndetected (the same `detectLocale` the i18n config uses):\n\n```ts\nimport { detectLocale, localeDir } from './i18n/config'\n\nconst locale = detectLocale(window.location.pathname)\ndocument.documentElement.lang = locale\ndocument.documentElement.dir = localeDir(locale)\n```\n\nEverything below inherits `dir` from `<html>`; logical CSS does the mirroring.\n\n### Vite SSR (test-harness vite-ssr)\n\nThe worker renders the full HTML, so set `lang`/`dir` on the server `<html>`\nfrom the **URL** locale (the client inherits it on hydration — no flash):\n\n```tsx\nimport { detectLocale, localeDir } from './i18n/config'\n\nconst locale = detectLocale(new URL(request.url).pathname)\nconst dir = localeDir(locale)\nconst html = `<!doctype html>\n<html lang=\"${locale}\" dir=\"${dir}\">\n …\n</html>`\n```\n\nParaglide's active locale must match: set it (via the i18n config's\n`setActiveLocale` / `overwriteGetLocale` bridge) before `renderToString`, so the\nSSR'd text and `dir` agree.\n\n### Next.js app-router (test-harness next-ssr / next-static)\n\nSet it on the `<html>` in `app/layout.tsx`. With locale-prefixed routes the\nsegment gives the locale; for a single-locale build it's a constant:\n\n```tsx\nimport { localeDir, defaultLocale } from './i18n/config'\n\nexport default function RootLayout({\n children,\n}: {\n children: React.ReactNode\n}) {\n const locale = defaultLocale // or the [lang] route segment / params\n return (\n <html lang={locale} dir={localeDir(locale)}>\n <body>{children}</body>\n </html>\n )\n}\n```\n\nFor `output: 'export'` with `/ar` prefixes, derive `locale` from the route\nsegment so each statically-exported tree carries the right `dir`.\n\n## Directional icons — the manual bit\n\nLogical properties mirror box layout, **not glyphs**. An icon that points\nsomewhere (chevron, back/next arrow, send, undo) must flip under RTL; a\nnon-directional icon (search, settings, avatar) must **not**. Flip with the\n`:dir()` selector — no JS, no per-locale branching:\n\n```css\n:dir(rtl) .icon-directional {\n transform: scaleX(-1);\n}\n```\n\nOr in CSS-in-JS / inline, gate on the resolved direction:\n`transform: localeDir(locale) === 'rtl' ? 'scaleX(-1)' : undefined`.\nPrefer logical icon components if your icon set ships them.\n\n## Arabic typography niceties\n\n- **Font:** the default Latin stack renders Arabic with the system fallback,\n which is inconsistent. Add an Arabic-capable family (e.g. _Noto Sans Arabic_,\n _IBM Plex Sans Arabic_) to `font-family` so both scripts look intentional.\n- **Numerals:** don't hardcode digits. Format numbers/dates with\n `Intl.NumberFormat`/`Intl.DateTimeFormat` given the active locale, so Western\n vs Arabic-Indic digits follow the locale choice.\n- **Line height:** Arabic diacritics sit tall — a slightly larger `line-height`\n on Arabic body text avoids clipping. Keep it locale-scoped, not global.\n\n## Adding Arabic to an existing app — checklist\n\n1. `messages/ar.json` mirroring `en.json`; add `\"ar\"` to `locales` in\n `project.inlang/settings.json` and recompile. Keys missing from `ar.json`\n fall back to the base locale per message rather than failing the build, so\n diff the two files rather than trusting `tsc` to catch a gap here.\n2. Confirm the `localeDir` helper includes `ar` (it does by default).\n3. Confirm the root sets `dir` from the locale (recipe above).\n4. Sweep the app's styles: replace every `left/right`, `ml/mr`, `text-align:\nleft` with the flow-relative equivalent; revert any manual `row-reverse`.\n5. Flip directional icons.\n6. `tsc`, then load the Arabic route and verify the whole layout mirrors —\n sidebar on the right, text right-aligned, arrows pointing the other way.\n\n## What NOT to do\n\n- Don't use physical `left`/`right` (or `ml`/`mr`) in any new layout style — even\n in an English-only app. Writing logical from the start is the seam Arabic\n slots into, exactly like tokens are for copy.\n- Don't fake RTL with `flex-direction: row-reverse`, reversed DOM order, or\n per-locale `if (rtl)` layout branches. Set `dir` once; let layout follow.\n- Don't set `dir` on individual components — it belongs on `<html>` so the whole\n document (and Mantine) agrees.\n- Don't translate Arabic copy outside the message system; an RTL language is a\n normal locale, governed by `pikku-i18n`. There is no `t()` and no i18next in a\n Pikku frontend — the string comes from `m.some__key()`.\n", "pikku-scenario/SKILL.md": "---\nname: pikku-scenario\ndescription: >-\n Use when writing or running Pikku scenarios, or when asked to test Pikku functions or improve\n test coverage. A scenario (pikkuScenario) drives the app the way users do — steps run as actors\n over the real transport against a running server — so a flow doubles as an e2e test and a\n staged/production health check. Covers scenario.do / expectEventually / expectError /\n expectService / expectScore, declared steps via pikkuScenarioStep (including browser steps driven by\n @pikku/playwright) written as intent rather than as clicks, with the actions factored into\n shared browser utilities, actors and environments in pikku.config.json, SCENARIO_ACTOR_SECRET, the\n `pikku scenario list|run` commands, live function coverage via `pikku dev --coverage`, and\n plain unit tests for pure function logic. TRIGGER when: user asks about scenarios, testing a\n Pikku function, test coverage, end-to-end flows, browser/UI e2e, or health checks. DO NOT\n TRIGGER when: user asks about running an existing test suite (use Bash) or CI configuration.\ninstallGroups: [core]\n---\n\n# Pikku Scenarios\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing: `pikku scenario list` for what exists, `pikku info functions --verbose` for what a scenario can call.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, or build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated.\n4. Validate with the narrowest relevant command first, then `pikku all --tsc` when functions, wirings or schemas may have changed.\n5. If validation fails, fix the source cause and rerun. Do not paper over generated errors by editing generated files.\n\n**`pikku tests` does not exist.** It was removed in #865 — scenarios own coverage now. Any reference you find to it is stale.\n\n## What a scenario is\n\nA scenario is a `pikkuScenario` export that drives the app **as real actors over the real transport**, against a running server. That is what lets one artifact serve as both an e2e test and a staged/production health check.\n\nConsequences that matter, and bite if ignored:\n\n- **There is no state reset.** A scenario runs against a live server. Scope what you create (unique ids, your own rows) and never assume a clean database.\n- **Every effect runs as somebody, or as a declared step.** `scenario.do(...)` without `{ actor }` throws `Scenario tried to run '<rpc>' as an internal step…` — there is no bare internal-RPC step. The other way to do work is `scenario.given/when/then`, which runs a `pikkuScenarioStep`; its actor is optional (setup steps have none) unless it declares `browser: true`.\n- **Actors must be configured and signed in**, or the scenario cannot run.\n\nScenarios live in `srcDirectories` like any other function — by convention `*.scenario.ts`.\n\n## Writing one\n\n`pikkuScenario` comes from the **generated** workflow types, not `@pikku/core`:\n\n```typescript\nimport { pikkuScenario } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nexport const orderSupportScenario = pikkuScenario<\n { value?: number },\n { doubled: number; message: string }\n>({\n title: 'Order support (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, data, { scenario, actors }) => {\n if (!actors?.shopper || !actors?.support) {\n throw new Error(\n 'orderSupportScenario needs run actors (shopper + support) — run via `pikku scenario run <environment>`'\n )\n }\n\n const doubled = await scenario.do(\n 'shopper doubles their order',\n 'doubleValue',\n { value: data?.value ?? 21 },\n { actor: actors.shopper }\n )\n\n const settled = await scenario.expectEventually(\n 'support sees the greeting settle',\n 'formatMessage',\n { greeting: 'Hello', name: 'Support' },\n (out: { message: string }) => out.message.length > 0,\n { actor: actors.support, within: '5s', interval: 50 }\n )\n\n return { doubled: doubled.result, message: settled.message }\n },\n})\n```\n\nA scenario takes the same config fields as a workflow (`title`, `description`, `tags`, `input`/`output`, `auth`, `permissions`, `middleware`, `version`, …). The third argument is the scenario context: `{ scenario, actors }`.\n\n### The scenario API\n\n| Call | Purpose |\n| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |\n| `scenario.do(step, rpc, data, { actor })` | Run an RPC as that actor. The step name is what appears in the run output. |\n| `scenario.expectEventually(step, rpc, data, predicate, { actor, within, interval })` | Poll until `predicate(out)` passes or `within` elapses. For anything asynchronous — queues, workers, eventual state. |\n| `scenario.expectError(step, rpc, data, { actor, matches })` | Assert the call **fails**. For fault injection and negative paths. |\n| `scenario.expectService(step, 'service.method', { actor, calledWith })` | Assert a stubbed service was called. Requires the server to run with `--test`. |\n| `scenario.expectScore(step, runId, scorer, { atLeast, atMost, reference })` | Grade a finished agent run with a declared scorer and assert the score. See below. |\n| `scenario.given(stepName, step, data, { actor })` | Run a declared `pikkuScenarioStep` as setup. `when` is the same call; `then` also makes the step's bindings witnesses. |\n| `scenario.runScheduledTask(name)` | Fire a wired scheduler on the target now, rather than waiting for its cron. |\n\n`expectEventually` is **scenario-only**. Calling it from a `pikkuWorkflowFunc` is a critical inspector error (`PKU675`) pointing you at `pikkuScenario`.\n\nPrefer `expectEventually` over sleeping.\n\n### Asserting on an agent's answer (`expectScore`)\n\nAn agent's output is not comparable to a fixed string, so it is graded rather\nthan matched. Declare the rubric with `pikkuAIScorer` (grades in code) or\n`pikkuAIJudge` (grades with a model) in a `*.scorer.ts` file, name it on the\nagent's `scorers`, then assert on the run the scenario just triggered:\n\n```typescript\nconst { runId } = await scenario.when('asks for a summary', 'runAssistant', {\n prompt: data.prompt,\n}, { actor: actors.user })\n\nawait scenario.expectScore('answered briefly', runId, 'brevity', { atLeast: 0.8 })\n```\n\nThe default bound is `atLeast: 0.5`, so an unqualified `expectScore` still fails\na run the scorer graded zero. `atMost` is for a rubric where high is the failure\n(sycophancy, verbosity). `reference` supplies the answer key a\n`requiresReference` judge grades against — live traffic has none, so such a\njudge is only ever reachable from a scenario.\n\nGrading goes through the `pikkuScenarioGradeRun` instrumentation RPC on the\nserver under test, which grades from the snapshot the runtime kept at the end of\nthe run. Two consequences: the run must have happened on **that** server and be\nrecent, and the grade is returned to the scenario rather than recorded — a\ntest's score never lands among the production figures. Sampling is ignored, so a\nscorer set to grade 1% of live traffic still grades every scenario run.\n\nTag any scenario whose scorer is a judge `ai-live`: it costs a model call, and\nthe default suite excludes it.\n\n### Setup and teardown (`before` / `after`)\n\nA scenario config takes `before` and `after`. Both have the **same signature as `func`** — `(services, data, wire)` — with the return value discarded:\n\n```typescript\nconst resetsCredentials = async (_services, _data, { actors }) => {\n await actors!.admin!.invoke('resetCredentials', {})\n}\n\nexport const credentialScenario = pikkuScenario({\n title: 'A credential is loaded on first use',\n tags: ['scenario', 'credential'],\n before: resetsCredentials,\n after: removesInstalledAddon,\n func: async (services, data, { scenario, actors }) => {\n /* … */\n },\n})\n```\n\n| Rule |\n| -------------------------------------------------------------------------------------------------------- |\n| `before` throwing skips the body and fails the run — but `after` still runs. |\n| `after` always runs, in a `finally`, whether the scenario passed or failed. |\n| `after` throwing fails a run that would otherwise have passed. |\n| `after` throwing on an already-failed run attaches as the `cause` and never replaces the original error. |\n| Neither runs when the run is suspended or waiting — teardown only fires at a terminal outcome. |\n| Hooks are **not** ladder rows. The runner records nothing for them; a failure is labelled by phase. |\n\nA hook reaches the app the same way the body does: through `wire.actors`. If you want cleanup to be _visible_ on the ladder, make it an ordinary `scenario.then(...)` instead.\n\nHooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs — a workflow is durable and resumable, so a callback that reran on every replay would have no honest meaning.\n\n### Grouping scenarios (`pikkuFeature`)\n\n`pikkuFeature` groups scenarios the way gherkin's `Feature:` groups `Scenario:`. Scenarios are referenced by **imported identifier**, so a renamed or deleted scenario is a compile error rather than a silent skip:\n\n```typescript\nimport { pikkuFeature } from '#pikku/workflow/pikku-workflow-types.gen.js'\nimport {\n credentialLazyLoadScenario,\n credentialRoundTripScenario,\n} from './credential.scenario.js'\n\nexport const credentialFeature = pikkuFeature({\n name: 'Credential API',\n description: 'Credentials resolve lazily and are scoped per user',\n tags: ['credential'],\n before: startsMockOAuthServer,\n after: stopsMockOAuthServer,\n scenarios: [\n credentialLazyLoadScenario,\n ...['stripe', 'google', 'hmac-key'].map((name) => ({\n scenario: credentialRoundTripScenario,\n data: { name },\n })),\n ],\n})\n```\n\n| Rule |\n| --------------------------------------------------------------------------------------------------------------------------------------------- |\n| The **export identifier is the feature's id**; `name` is the human-readable label. Both must be exported or the build fails. |\n| A `{ scenario, data }` entry is gherkin's `Examples:` — one run per entry. `data` is typed against that scenario's input. |\n| Feature hooks run **once around the whole group** (`before → a → b → c → after`), _not_ per scenario. `after` runs in a `finally`. |\n| There is deliberately **no `Background:`**. Per-scenario setup is the scenario's own `before`, referencing a shared function. |\n| A scenario's effective tags are its own **plus** the feature's, so `--tags credential` selects through the feature. |\n| A scenario need not belong to a feature — one with no input still runs standalone. |\n| Membership is resolved by **object identity** at runtime, which is why a loop works and why a scenario built inline in a feature is an error. |\n\nThe **feature is the run unit**: `--flows` on a scenario whose every feature entry carries `data` errors and names the features containing it, because the feature is what supplies that data. Use `--features` for those. A scenario referenced bare anywhere, or in no feature at all, still runs standalone.\n\n### Steps describe intent, not actions\n\nA scenario records what someone was **trying to do**, never the keystrokes they used to do it. This is the one decision that determines whether a suite survives its first redesign, and it applies to every step name you write.\n\n| Action ladder — wrong | Intent ladder — right |\n| ----------------------------------- | --------------------------------------------------- |\n| `Given opens /shop` | `Given the shopper is browsing the shop` |\n| `When clicks the category filter` | `When the shopper buys the £5 strawberry milkshake` |\n| `And clicks \"Drinks\"` | `Then it is in their basket` |\n| `And clicks the first product card` | |\n| `And clicks Add to basket` | |\n| `Then sees \"1 item\"` | |\n\nThree things go wrong with the left-hand column, and all three are expensive:\n\n- **A layout change rewrites every scenario that touched that screen.** In the right-hand column it rewrites one function.\n- **The report is the deliverable.** `buys the £5 strawberry milkshake` is readable by someone who has never seen the app; `clicks [data-testid=add]` tells them nothing about whether the product works.\n- **An action step cannot arrive on its own.** It assumes the previous click left the browser somewhere, so the scenario only runs front-to-back, as a whole, in one order.\n\nSo there are three layers, and only two of them are named in the report:\n\n| Layer | What it is | On the ladder |\n| -------------------------- | -------------------------------------- | ---------------- |\n| Scenario | The flow, written as intents | yes — the ladder |\n| Step (`pikkuScenarioStep`) | One intent | yes — one row |\n| Utility | An ordinary TS function over `browser` | no |\n\nUtilities are **not steps**. They are plain exported functions, they take the browser handle, and they hold the clicking:\n\n```typescript\n// shop.browser.ts — shared actions. Not steps: nothing here is an intent.\nimport type { PikkuBrowserWire } from '@pikku/core/workflow'\nimport type {} from '@pikku/playwright'\n\n/** Arrive on the shop, from wherever the browser happens to be. */\nexport const ensureOnShop = async (browser: PikkuBrowserWire) => {\n if (!new URL(browser.page.url()).pathname.startsWith('/shop')) {\n await browser.goto('/shop')\n }\n await browser\n .locate({ testId: 'product-grid' })\n .first()\n .waitFor({ state: 'visible' })\n}\n\nexport const searchFor = async (browser: PikkuBrowserWire, query: string) => {\n await browser.locate({ testId: 'shop-search' }).first().fill(query)\n await browser.page.keyboard.press('Enter')\n}\n\nexport const filterByCategory = async (\n browser: PikkuBrowserWire,\n category: string\n) => {\n await browser.locate({ testId: 'category-filter' }).first().click()\n await browser\n .locate({ testId: 'category-option', where: { 'data-category': category } })\n .first()\n .click()\n}\n\nexport const addToBasket = async (browser: PikkuBrowserWire, name: string) => {\n const card = browser\n .locate({ testId: 'product-card', containing: name })\n .first()\n await card.waitFor({ state: 'visible' })\n await card.locate('[data-testid=add-to-basket]').click()\n}\n```\n\nThe step composes them, and it is the step — one row — that the report shows:\n\n```typescript\nexport const buysTheItem = pikkuScenarioStep<\n { name: string },\n { name: string }\n>({\n name: 'buysTheItem',\n description: 'finds one item in the shop and puts it in the basket',\n template: 'buys the {name}',\n // One intent, one implementation per surface an actor can drive it through.\n browser: async (_services, { name }, { browser }) => {\n await ensureOnShop(browser)\n await searchFor(browser, name)\n await addToBasket(browser, name)\n return { name }\n },\n default: async ({ rpc }, { name }) => {\n const item = await rpc.invoke('findItemByName', { name })\n await rpc.invoke('addToBasket', { itemId: item.id })\n return { name }\n },\n})\n```\n\nThe bindings are **alternatives**: `pikku scenario run --run browser` clicks through the shop, `--run cli` drives it over the websocket, `--run default` (the fast suite, and the default) takes the server-side path — and all of them report the same sentence.\n\n```typescript\nawait scenario.when(\n 'buys a milkshake',\n 'buysTheItem',\n { name: '£5 strawberry milkshake' },\n { actor: actors.shopper }\n)\n// reporter renders: When the shopper buys the £5 strawberry milkshake ✓ 1.2s\n```\n\n**Every intent step begins by arriving.** `ensureOnShop` is not defensive noise — it is what lets a scenario start at any step, run alone, and be reordered without touching it. It checks first and navigates only if needed, so a scenario already on the shop pays nothing. This is about the _browser's_ starting position, not the database: there is still no state reset (see above), and you still scope what you create.\n\n**The same utilities, a different intent.** A scenario about filtering has filtering as its subject, so there the filter _is_ the intent — same helper, its own step:\n\n```typescript\nexport const filtersTheShop = pikkuScenarioStep<\n { category: string },\n { shown: number }\n>({\n name: 'filtersTheShop',\n description: 'narrows the catalogue to one category',\n template: 'filters the shop by {category}',\n browser: async (_services, { category }, { browser }) => {\n await ensureOnShop(browser)\n await filterByCategory(browser, category)\n return {\n shown: await browser.locate({ testId: 'product-card' }).count(),\n }\n },\n default: async ({ rpc }, { category }) => ({\n shown: (await rpc.invoke('listItems', { categorySlug: category })).length,\n }),\n})\n```\n\nTwo scenarios, two intents, one set of utilities. That is the shape to aim for: when a helper is reused by a step whose _subject_ it is, promote it to a step there — never the reverse.\n\n**Non-browser steps need none of this.** Without a browser there is no navigation to absorb and no DOM to hide, so an intent maps to one RPC and `scenario.do` names it directly:\n\n```typescript\nconst order = await scenario.do(\n 'Shopper checks out',\n 'createOrder',\n { basketId, shippingAddress },\n { actor: actors.shopper }\n)\n```\n\nReach for a `pikkuScenarioStep` on the non-browser side only when one intent genuinely spans several RPCs, or when the step asserts something the RPC result alone does not say.\n\n### `then` bindings are witnesses, not alternatives\n\nThis is the one place the surface bindings do **not** behave like a switch, and it is the part worth reading twice.\n\nOn a `given` or `when`, the bindings are alternatives — clicking Buy and calling `createOrder` are two ways to cause one effect, so exactly one runs.\n\nOn a `then`, they are not two implementations of one assertion. They are two _different claims_:\n\n| binding | what it actually proves |\n| --------- | -------------------------------------------------------------- |\n| `default` | the order row says `paid` — the system of record is right |\n| `browser` | the confirmation panel says paid — the truth reached the human |\n\nThe gap between them is the bug nobody catches: 200 OK, database correct, user still watching a spinner. So a `then` runs **every** binding it declares and fails if they disagree.\n\n```typescript\nexport const seesTheOrderConfirmed = pikkuScenarioStep<\n { orderId: string },\n { status: string }\n>({\n name: 'seesTheOrderConfirmed',\n template: 'sees order {orderId} confirmed',\n // Both run on `--run browser`. Each returns what it observed, and the runner\n // compares them — so this fails when the page disagrees with the database.\n browser: async (_services, { orderId }, { browser }) => ({\n status: await browser\n .locate({ testId: 'order-status', where: { 'data-order': orderId } })\n .getAttribute('data-status'),\n }),\n default: async ({ rpc }, { orderId }) => ({\n status: (await rpc.invoke('getOrder', { orderId })).status,\n }),\n})\n```\n\nThree rules follow, and they are the ones that get broken:\n\n- **A browser witness must observe on the page.** One that quietly calls an RPC to check the result is worse than no binding at all — it reports a tick for a surface it never looked at.\n- **Return what you observed, don't just assert.** A witness returning a value lets the runner diff the two. A witness that only throws still works, but it can never disagree with anything, so it proves less. Read structured state with `where` on the test-id selector rather than parsing translated copy.\n- **A step with no binding for the run's surface is counted, not excused.** `--run browser` prints `n/m steps ran on browser` over _every_ step, so an action that quietly fell back to the server lowers the number just as an assertion does. A `then` that fell back is additionally named — `--strict` fails on those, because a sentence saying the actor saw something nobody looked at is a different problem from a shortcut. Not being in the UI _is_ the finding: do not add a browser binding that fakes it.\n\n**Always give a `then` a `default` witness.** It is the floor every run can fall back to, and an assertion with no witness the run can execute is fatal (`ScenarioNoWitness`) — not a coverage gap. The distinction is the point: a `then` checked server-side under `--run browser` did happen, it just wasn't seen where the prose claims; one checked nowhere never happened at all, and without the error it would return `undefined` and render as a tick. A browser-only `then` is therefore a step that fails the fast suite, which is rarely what you want.\n\n**Every scenario must assert.** A flow of only `given`/`when` is a PKU680 critical — it proves nothing threw. Since coverage counts every step, an assertion-free ladder of browser-bound actions would score a perfect `3/3` while checking nothing, so clicking through the UI and never looking at the result is the cheapest way to fake the number. The rule closes that.\n\nAssertions with no possible browser witness are a different thing and should not be written as a `then`: \"the audit log recorded it\" is a system check, and \"the receipt email arrives\" is `expectEventually`, which is always out-of-band and always server-side.\n\n### Declared steps (`pikkuScenarioStep`)\n\n`scenario.do` can only name an RPC. A **step** is a named, typed unit of scenario behaviour whose body is an ordinary pikku function — so it can call several RPCs as its actor, assert, or drive a browser.\n\n```typescript\nimport { pikkuScenarioStep } from '#pikku/workflow/pikku-workflow-types.gen.js'\nimport { requireActor } from '@pikku/core/workflow'\n\nexport const buysAnApple = pikkuScenarioStep<\n { qty: number },\n { orderId: string }\n>({\n name: 'buysAnApple',\n description: 'buys an apple',\n template: 'buys {qty} apples',\n default: async (_services, { qty }, { scenarioStep }) => {\n return await requireActor(scenarioStep).invoke('placeOrder', { qty })\n },\n})\n```\n\nA step's body always lives under a **surface binding** — `default`, `browser` or\n`cli` — never under a `func`. Declaring none throws at load time: at minimum give\nit a `default`.\n\n```typescript\nawait scenario.given(\n 'buys an apple',\n 'buysAnApple',\n { qty: 1 },\n { actor: actors.shopper }\n)\n// reporter renders: Given the shopper buys 1 apples ✓ 412ms\n```\n\nRules that bite:\n\n- **The step is referenced by its typed string name, not by importing the const** — exactly like `workflow.do`. The name is the step's `pikkuFuncId` and is checked against the generated step map. A non-literal target is a critical error (`PKU678`).\n- **Steps are not RPCs.** They are deliberately never network-callable — a browser-driving step must not be.\n- **`actor.invoke` is typed over the exposed RPC map**, so the name and the payload are checked and the result comes back narrowed — no cast. `actor.invokeRaw(name, data, { headers })` is the same call reporting `{ status, ok, body }` instead of throwing; use it whenever the refusal _is_ the assertion.\n- **`actor` and `env` are optional on the wire**, because a pure assertion step needs neither. Narrow them with `requireActor(scenarioStep)` and `requireScenarioEnv(scenarioStep)` from `@pikku/core/workflow` rather than a local guard — both name the step and say what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.\n- **Steps default to `retries: 0`**, unlike ordinary workflow steps. Retrying a failed assertion is wrong; pass `retries` explicitly if a step is genuinely flaky-by-nature.\n- **Step results are persisted**, so return JSON-serialisable data — never a `Locator` or a client object.\n- **`description` documents the step; `template` is what the report renders.** `template`'s `{placeholders}` are filled from the input the step was called with, so one step reads differently for each call — `sees {state} addon {packageName}` reports as \"sees available addon @pikku/addon-stripe\". Reflect every input field in the template, and type the values so they read as words (`state?: 'installed' | 'available'`, not `installed?: boolean`). A placeholder with no value renders as nothing and the whitespace collapses.\n- Prose precedence is `options.description` → the step's `template` → the step's own `description` → the positional step name. Repeated names get `#1`, `#2` ordinals, so a `for` loop over a data set is how you write a Scenario Outline. A loop-generated step name is not statically known, so it is matched back to its declaration by step function instead — which works as long as that function's call sites agree on their phase, actor and prose. Two call sites that disagree make the loop step report under its bare runtime name.\n\n### Browser steps\n\nDeclaring a `browser` binding is the whole switch: inside that binding `wire.browser` is guaranteed present and non-optional, and a step without one never sees a browser at all. There is nothing to null-check.\n\nA `browser` binding gets a session bound to **its actor**, signed in through the same `signInPath` + `SCENARIO_ACTOR_SECRET` path the HTTP actors use, so the browser and the RPC calls are one identity. Calling such a step without an actor is a critical error (`PKU677`).\n\nBrowser steps are where **intent, not actions** earns its keep: the step is one intent, the clicking lives in shared utilities, and the step arrives before it acts. Write the mechanics below into utilities and keep the step body to three or four calls that read as a sentence.\n\n```typescript\nexport const opensTheCart = pikkuScenarioStep<{ path: string }, { url: string }>(\n {\n name: 'opensTheCart',\n description: 'opens the cart',\n browser: async (_services, { path }, { browser }) => {\n await browser.goto(path)\n return { url: browser.page.url() }\n },\n default: async ({ rpc }) => ({ url: (await rpc.invoke('getCart', {})).url }),\n }\n)\n```\n\n- Install `@pikku/playwright` and `@playwright/test`, and import `@pikku/playwright` once (`import type {} from '@pikku/playwright'`) so `browser.page` is a typed Playwright `Page`. Without it you still get the structural `goto`/`screenshot` handle.\n- The environment needs an `appUrl` beside its `apiUrl`. `pikku scenario run` fails fast before running anything if a browser scenario has no `appUrl` or the driver is not installed.\n- `pikku scenario run <env> --no-browser` **skips** scenarios containing browser steps and reports them as skipped — it does not fail them. That is how a machine with no browser stays green.\n- Playwright auto-waits; do not wrap `page.click` in `expectEventually`.\n\n## Configuration\n\nPersonas, actors and environments live in `pikku.config.json`:\n\n```json\n{\n \"scenarios\": {\n \"personas\": {\n \"shopper\": { \"description\": \"Buys things here\", \"primary\": true },\n \"support\": {\n \"description\": \"Answers for the shop\",\n \"proficiency\": \"power\"\n },\n \"reminders\": {\n \"description\": \"The shop chasing abandoned carts\",\n \"kind\": \"system\"\n }\n },\n \"actors\": {\n \"shopper\": {\n \"email\": \"shopper@actors.local\",\n \"name\": \"Shopper\",\n \"jobTitle\": \"First-time buyer\",\n \"personality\": \"Impatient shopper who abandons slow checkouts\"\n },\n \"shopperB\": { \"persona\": \"shopper\", \"email\": \"shopper-b@actors.local\" }\n },\n \"environments\": {\n \"local\": {\n \"apiUrl\": \"http://localhost:4077\",\n \"signInPath\": \"/api/auth/sign-in/actor\"\n }\n }\n }\n}\n```\n\n### Personas and actors\n\nA **persona** is a kind of person; an **actor** is one body that signs in as one. Above, `support` is declared only as a persona — its actor is materialised (`support@actors.local`), so `actors.support` works without an `actors` entry. Write an actor by hand only when you need something the materialised one wouldn't have:\n\n- a **real email or personality** for it, like `shopper`;\n- a **second body of the same persona**, like `shopperB` — which is what tenant isolation, peer sharing, and \"another member's row\" scenarios are made of. Two actors of one persona must be two different users, so **two actors sharing an email is an error**.\n\nA persona holds only what is true of that kind of person for the app's whole lifetime — `description`, `primary` (whose experience the product is), `kind`, `proficiency`. What someone is trying to get done, and the circumstances they are doing it in, belong to the **scenario**, not to them.\n\n`kind: \"system\"` is the app acting on its own — a schedule, a cleanup, a send. It gets **no actor**: there is nobody to sign in. Give it one by hand only if it genuinely has a service account.\n\nAn actor with no `persona` is its own persona, so a project that never declares any keeps working unchanged.\n\n- `environments.<name>.apiUrl` is required. `signInPath` defaults to `/auth/sign-in/actor`, `rpcPath` to `/rpc`.\n- **`SCENARIO_ACTOR_SECRET` is an environment variable and never goes in `pikku.config.json`.** It signs actors in. `pikku scenario run` throws without it; a server auto-building actors warns and runs without them.\n\n## Running\n\n```bash\npikku scenario list # features with their scenarios indented, then ungrouped scenarios\nSCENARIO_ACTOR_SECRET=… pikku scenario run local\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --flows orderSupportScenario\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --features credentialFeature\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --tags smoke,scenario\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --spawn --no-browser --exclude-tags ai-live\n```\n\n`run` takes the environment as a **required positional** — the key from `environments`. Every filter narrows the same plan, so narrowing a feature to two of its five scenarios still runs the feature's hooks exactly once around those two.\n\n| Flag | Effect |\n| ----------------------- | --------------------------------------------------------------------------------------- |\n| `--flows` / `-f` | Comma-separated scenario names |\n| `--features` | Comma-separated feature ids |\n| `--tags` / `-t` | Match-any tag filter |\n| `--exclude-tags` | Hold tags back — unless the flow is named directly with `--flows` |\n| `--run <surface>` | `default` (the default), `browser`, or `cli` |\n| `--no-browser` | Shorthand for `--run default`; scenarios with browser steps report as **skipped** |\n| `--strict` | Fail, rather than pass, a `then` with no witness on the run's surface |\n| `--spawn` / `--keep-alive` | Start `pikku dev` on the environment's apiUrl for the run; optionally leave it up |\n| `--api-url` / `--app-url` | Override the environment's URLs — for a target that only exists at run time |\n| `--trace` | Keep every stack frame on failure (default shows only the project's own) |\n| `--coverage` | Reset/snapshot server coverage per scenario |\n\nOutput is `PASS <name> (<ms>) → <output>` / `FAIL <name> (<ms>): <error>`, then `N/M scenarios passed against '<env>'`. A scenario inside a feature is named `<Feature> › <scenario> <data>`.\n\n**Exit code is 1** if any scenario fails _or_ if no scenario matched the filter — a typo'd `--flows` is a hard error, not a silent zero-run pass. It throws outright on an unknown environment, an unknown flow name, or a missing `SCENARIO_ACTOR_SECRET`.\n\n## Coverage\n\nCoverage is attributed by running scenarios against a server that is collecting it. It is **not** derived from unit tests.\n\nPrerequisite in `pikku.config.json`:\n\n```bash\npikku enable scenarios # sets scaffold.scenarios = true (session required)\npikku enable scenarios --noAuth # sets scaffold.scenarios = { \"auth\": false }\n```\n\n`scaffold.scenarios` is a boolean or `{ auth?, path? }`. The legacy string forms\n(`\"auth\"` / `\"no-auth\"`) are **rejected by the config loader**, not reinterpreted —\nunder a shape where a string could be a path, silently reading one as a flag\nwould be worse than failing.\n\n`scaffold.scenarios` generates the coverage and stub RPCs into your project (`pikkuScenarioTakeLiveCoverage`, `pikkuScenarioResetLiveCoverage`, `pikkuScenarioResetStubs`, `pikkuScenarioGetStubCalls`), so scenario runs work against any server. The coverage RPC reads `<outDir>/function/pikku-functions-meta-verbose.gen.json` off disk at request time — codegen always writes it, but it has to be deployed alongside the app or the RPC returns `null`.\n\n```bash\npikku dev --coverage # V8 precise coverage, in-process\npikku dev --coverage --test # also enable stubs (needed for expectService)\nSCENARIO_ACTOR_SECRET=… pikku scenario run local --coverage\n```\n\nThe run resets coverage before each scenario and snapshots after, writing **`<outDir>/coverage/scenario-coverage.json`**:\n\n```jsonc\n{\n \"generatedAt\": \"…\",\n \"environment\": \"local\",\n \"scenarios\": {\n \"<name>\": {\n /* FunctionCoverageReport */\n },\n },\n}\n```\n\nCoverage is best-effort: it disables itself with a warning if the server is not collecting or the first actor cannot invoke, and it needs at least one configured actor. If you get no coverage, check those first.\n\n**There is no AI-prompt output.** The old `--ai-out` flag died with `pikku tests`; nothing replaced it. To find what needs work, read `scenario-coverage.json` yourself and cross-reference `pikku meta functions list` for input/output schemas.\n\n### Filling coverage\n\n1. `pikku scenario run <env> --coverage`, then read `<outDir>/coverage/scenario-coverage.json` to see what is unexercised.\n2. `pikku meta functions list` for those functions' schemas.\n3. Write a `pikkuScenario` that reaches them **through a real user flow** with an actor — not a scenario per function. Scenarios are flows; coverage is a consequence.\n4. Re-run to confirm.\n\n## Unit tests for pure logic\n\nScenarios are the repo-idiomatic way to test functions, and the only thing that contributes to live coverage. For pure logic with heavy branching, a plain unit test calling `func` directly is still valid and cheap:\n\n```typescript\nimport { describe, test } from 'node:test'\nimport assert from 'node:assert'\n\ndescribe('createTodo', () => {\n test('creates a todo', async () => {\n const services = {\n todoStore: { add: async (title: string) => ({ id: '1', title }) },\n }\n const result = await createTodo.func(services as any, { title: 'Buy milk' })\n assert.equal(result.title, 'Buy milk')\n })\n})\n```\n\n```bash\nnode --import tsx --test src/**/*.test.ts\n```\n\nServices are plain objects — a Pikku function is pure business logic, so a mock is just the shape the function destructures. Build real services via the `pikkuServices` / `pikkuWireServices` factories when a test needs them.\n\n## Red flags\n\n| Smell | Why it's wrong |\n| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| `pikku tests …` | Removed in #865. Use `pikku scenario`. |\n| `.feature` files / Gherkin for function tests | Scenarios are TypeScript, not Gherkin. The in-process cucumber function world was deleted. |\n| `scenario.do(...)` with no `{ actor }` | Throws. Every step runs as somebody. |\n| A scenario per function | Scenarios are user flows. One flow covers many functions; that is the point. |\n| Assuming a clean database | There is no state reset — it may be a staging server. Scope what you create. |\n| `sleep()` before asserting | Use `expectEventually`. |\n| A step named `clicksAddToBasket` / `opensThePage` | That is an action, not an intent. Name the step for what the actor wanted; put the clicking in a utility. |\n| A browser step that assumes it is already on a page | It can then only run mid-flow. Arrive first — check the URL, navigate if needed. |\n| A `browser` binding guarding `if (!browser)` | The binding guarantees it. The guard hides the real error, which is a missing actor (`PKU677`). |\n| A step with a `func:` instead of a surface binding | There is no `func` on a step. Bodies live under `default` / `browser` / `cli`; a step with none throws at load. |\n| `expectEventually` in a `pikkuWorkflowFunc` | `PKU675` — scenario-only. |\n| Coverage silently 0 | Server not run with `--coverage`, verbose functions meta not deployed, `scaffold.scenarios` unset, or no actors configured. |\n\n`@pikku/cucumber` is a **browser/e2e** harness (`Actor`, `BrowserWorld`, `PersonaData`, `DbUtils`) — out of scope here.\n\nSee `pikku-concepts` for the core mental model.\n", "pikku-schedule/SKILL.md": "---\nname: pikku-schedule\ndescription: >-\n Use when setting up in-memory cron scheduling in a Pikku app. Covers InMemorySchedulerService\n for running scheduled tasks. TRIGGER when: code uses InMemorySchedulerService,\n PikkuTaskScheduler, or user asks about in-memory scheduling, cron jobs without external\n dependencies, or @pikku/schedule. DO NOT TRIGGER when: user asks about cron wiring (use\n pikku-cron) or queue-based scheduling with BullMQ/PgBoss (use pikku-queue).\ninstallGroups: [core]\n---\n\n# Pikku Schedule (In-Memory Scheduler)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/schedule` provides an in-memory cron scheduler for running Pikku scheduled functions without external dependencies like Redis or PostgreSQL.\n\n## Installation\n\n```bash\nyarn add @pikku/schedule\n```\n\n## API Reference\n\n### `InMemorySchedulerService`\n\n```typescript\nimport { InMemorySchedulerService } from '@pikku/schedule'\n\nconst schedulerService = new InMemorySchedulerService()\nawait schedulerService.start() // registers a CronJob per wired scheduled task\n```\n\nIt implements core's `SchedulerService` on two mechanisms: `cron` for the\nrecurring tasks you declared with `wireScheduler` (see `pikku-cron`), and\n`setTimeout` for one-off delayed RPCs. Both live in process memory, so nothing\nsurvives a restart and nothing is shared between instances — fine for\ndevelopment and a single-instance deployment, wrong for anything else.\n\n`PikkuTaskScheduler` is a deprecated alias for the same class.\n\n### Scheduling a one-off RPC\n\n```typescript\nconst taskId = await schedulerService.scheduleRPC('5m', 'sendReminder', data, session)\nawait schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null\nawait schedulerService.getAllTasks() // pending one-offs only\nawait schedulerService.unschedule(taskId) // true when it was still pending\n```\n\nThe delay is milliseconds or a duration string (`'30s'`, `'5m'`, `'2h'`). This is\nalso the mechanism a workflow's delayed steps use, which is why a workflow that\nsleeps needs a `schedulerService` registered.\n\n## Usage Patterns\n\n### Basic Setup\n\nThe scheduler is a singleton service under the name **`schedulerService`**, and\nit is started in your server bootstrap — declaring it without calling `start()`\nregisters no cron jobs, so nothing ever fires:\n\n```typescript\n// start.ts\nimport { InMemorySchedulerService } from '@pikku/schedule'\n\nconst schedulerService = new InMemorySchedulerService()\nconst singletonServices = await createSingletonServices(config, {\n schedulerService,\n})\n\nawait appServer.start()\nawait schedulerService.start()\n```\n\nCall `close()` on shutdown — it stops every cron job and clears pending timers.\n\nFor distributed or persistent scheduling, take the scheduler service off the\nqueue factory instead (`bullFactory.getSchedulerService()`,\n`pgBossFactory.getSchedulerService()`) and register it under the same name. See\n`pikku-queue`.\n", "pikku-schema-ajv/SKILL.md": "---\nname: pikku-schema-ajv\ndescription: >-\n Use when setting up JSON schema validation with AJV in a Pikku app. Covers AjvSchemaService for\n request/response validation. TRIGGER when: code uses AjvSchemaService, user asks about AJV, JSON\n schema validation, or @pikku/schema-ajv. DO NOT TRIGGER when: user asks about Cloudflare Workers\n schema validation (use pikku-schema-cfworker).\ninstallGroups: [core]\n---\n\n# Pikku Schema AJV (JSON Schema Validation)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/schema-ajv` provides JSON schema validation using [AJV](https://ajv.js.org/). Implements the `SchemaService` interface from `@pikku/core`. This is the default schema validator for Node.js environments.\n\n## Installation\n\n```bash\nyarn add @pikku/schema-ajv\n```\n\n## API Reference\n\n### `AjvSchemaService`\n\n```typescript\nimport { AjvSchemaService } from '@pikku/schema-ajv'\n\nconst schema = new AjvSchemaService(logger: Logger)\n```\n\n**Methods:**\n\n- `compileSchema(name: string, schema: any): void` — Compile and register a JSON schema under `name`\n- `validateSchema(schemaName: string, json: any): void` — Validate data against a compiled schema (throws on failure)\n- `getSchemaNames(): Set<string>` — Get all registered schema names\n- `getSchemaKeys(schemaName: string): string[]` — Top-level property keys, or `[]` if the schema has no `properties`\n\nThe first argument is the **name**, the second the schema — the parameter is\ncalled `schema` in the source, which reads backwards.\n\n### Behaviour that matters\n\n- **Registration is name-keyed and never re-compiles.** A second\n `compileSchema('X', …)` with a different schema is a no-op; the first one wins\n for the process lifetime. `@pikku/schema-cfworker` *does* recompile on a\n changed value, so a dev hot-reload after codegen picks up a changed schema\n there but not here — restart the process instead.\n- **AJV is a module-level singleton**, shared by every `AjvSchemaService` you\n construct, so compiled schema names are global to the process.\n- **`useDefaults: true` mutates the validated object**, filling in schema\n defaults in place. `coerceTypes: false`, so a query-string `\"1\"` will not\n become `1` — the wiring layer is what coerces, not this service.\n- `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)\n are enforced.\n- A failed validation throws `UnprocessableContentError` (a 422). A *missing*\n schema throws a bare string, `Missing validator for <name>` — not an `Error`,\n so `catch (e) { e.message }` reads `undefined`. That normally means codegen\n didn't run.\n\n## Usage Patterns\n\n### With Pikku Services\n\n```typescript\nimport { AjvSchemaService } from '@pikku/schema-ajv'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const schema = new AjvSchemaService(logger)\n return { config, logger, schema }\n})\n```\n\nPikku automatically uses the schema service to validate function inputs and outputs when schemas are defined in your function definitions.\n", "pikku-schema-cfworker/SKILL.md": "---\nname: pikku-schema-cfworker\ndescription: >-\n Use when setting up JSON schema validation for Cloudflare Workers in a Pikku app. Covers\n CFWorkerSchemaService as a lightweight alternative to AJV. TRIGGER when: code uses\n CFWorkerSchemaService, user asks about schema validation on Cloudflare Workers, or\n @pikku/schema-cfworker. DO NOT TRIGGER when: user asks about AJV schema validation (use\n pikku-schema-ajv).\ninstallGroups: [core, fabric]\n---\n\n# Pikku Schema CFWorker (Cloudflare Workers Validation)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/schema-cfworker` provides JSON schema validation using [@cfworker/json-schema](https://github.com/cfworker/cfworker), a lightweight validator compatible with Cloudflare Workers (no `eval` or `new Function`). Implements the `SchemaService` interface from `@pikku/core`.\n\n## Installation\n\n```bash\nyarn add @pikku/schema-cfworker\n```\n\n## API Reference\n\n### `CFWorkerSchemaService`\n\n```typescript\nimport { CFWorkerSchemaService } from '@pikku/schema-cfworker'\n\nconst schema = new CFWorkerSchemaService(logger: Logger)\n```\n\n**Methods:**\n\n- `compileSchema(name: string, schema: any): void` — Compile and register a JSON schema under `name`\n- `validateSchema(schemaName: string, json: any): void` — Validate data against a compiled schema (throws on failure)\n- `getSchemaNames(): Set<string>` — Get all registered schema names\n- `getSchemaKeys(schemaName: string): string[]` — Top-level property keys, or `[]` if the schema has no `properties`\n\n### Where it differs from AJV\n\nThese two are not drop-in equivalents, and the differences are the kind that\nsurface as behaviour changes rather than compile errors:\n\n- **No `useDefaults`.** AJV fills schema defaults into the validated object in\n place; this validator does not. A field you relied on being defaulted arrives\n `undefined` on Workers.\n- **It re-compiles when the schema value changes.** AJV caches by name forever;\n here a `compileSchema` with a different value for the same name replaces the\n validator, which is what lets a dev hot-reload pick up regenerated schemas.\n- **Each validator gets a deep clone of the schema** (`@cfworker/json-schema`\n mutates what it is given, which throws on a frozen generated object).\n- A compile failure throws `Error('Failed to compile schema: <name>')` with the\n underlying cause swallowed — check the schema by hand when you see it.\n\nA failed validation throws `UnprocessableContentError` (422) with the validator\nerrors joined; a *missing* schema throws a bare string, `Missing validator for\n<name>`, not an `Error`.\n\n## Usage Patterns\n\n### With Cloudflare Workers\n\n```typescript\nimport { CFWorkerSchemaService } from '@pikku/schema-cfworker'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const schema = new CFWorkerSchemaService(logger)\n return { config, logger, schema }\n})\n```\n\nUse this instead of `@pikku/schema-ajv` when deploying to Cloudflare Workers, as AJV uses `eval` which is not permitted in the Workers runtime.\n", "pikku-security/SKILL.md": "---\nname: pikku-security\ndescription: >-\n Use when adding authentication or session management to a Pikku app — pikkuAuth, session\n lifecycle (setSession/clearSession), built-in auth strategies (authBearer, authCookie,\n authAPIKey), or JWT setup. TRIGGER when: user asks about login, logout, session, bearer tokens,\n cookie auth, API keys, or JWT. DO NOT TRIGGER when: user asks about middleware (use\n pikku-middleware), permissions/authorization checks (use pikku-permissions), or secrets/env vars\n (use pikku-config).\ninstallGroups: [core]\n---\n\n# Pikku Security (Authentication & Sessions)\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info middleware --verbose` and `pikku info functions --verbose` to understand existing auth setup.\n2. Auth strategies live in wirings files — do not put `addHTTPMiddleware` calls inside function bodies.\n3. Validate with `pikku all --tsc` after changes — it regenerates and then type-checks in one pass, and fails on type errors. Use `--tsc-summary` for a compact one-line-per-error report.\n\nFor **middleware** (including tag middleware and service-to-service bearer auth) see `pikku-middleware`.\nFor **permissions** (pikkuPermission, pikkuAuth, per-function authorization) see `pikku-permissions`.\n\n## Session Management\n\n`session`, `setSession` and `clearSession` live on the **wire** — the function's\nthird argument — not on services. `setSession`/`clearSession` may be async\n(cookie and session-store backends write on the way out), so await them.\n\n```typescript\n// Read session in pikkuFunc (session guaranteed to exist)\nconst getProfile = pikkuFunc({\n func: async ({ db }, _data, { session }) => {\n return await db.getUser(session.userId)\n },\n})\n\n// Set session (e.g., after login)\nconst login = pikkuFunc({\n auth: false,\n func: async ({ jwt, db }, { email, password }, { setSession }) => {\n const user = await db.verifyCredentials(email, password)\n await setSession({ userId: user.id })\n return { token: jwt.sign({ userId: user.id }) }\n },\n})\n\n// Clear session (logout)\nconst logout = pikkuFunc({\n func: async ({}, _data, { clearSession }) => {\n await clearSession()\n },\n})\n```\n\n`login` is `auth: false` because the caller has no session yet — a `pikkuFunc`\nwith the default `auth` would be rejected before its body ever ran.\n\n## Built-in Auth Strategies\n\nApply these via `addHTTPMiddleware` in a wirings file:\n\n```typescript\nimport { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'\nimport { addHTTPMiddleware } from '#pikku'\n\n// JWT bearer token — reads Authorization header\naddHTTPMiddleware('*', [authBearer()])\n\n// Cookie-based sessions — re-issues the cookie when the session changes\naddHTTPMiddleware('*', [\n authCookie({\n name: 'session',\n expiresIn: { value: 30, unit: 'day' },\n options: { sameSite: 'strict' },\n }),\n])\n\n// API key — from x-api-key header or ?apiKey= query param\naddHTTPMiddleware('*', [authAPIKey({ source: 'all' })])\n```\n\nAll three share the same escape hatch: they do nothing when there is no HTTP\nrequest, or when a session is already set. That is what lets you stack several —\nwhichever runs first and finds a credential wins, and the rest step aside — and\nit is also why none of them authenticate a queue job, a scheduled task or a\nchannel message. Those need a session set another way.\n\nEach decodes its credential with the `jwt` service; without one registered, they\nsilently authenticate nobody.\n\n**`authBearer` in static-token mode.** Passing `token` switches it from decoding\na JWT to comparing (in constant time) against a fixed value — the shape to use\nfor a service-to-service caller or a demo:\n\n```typescript\nauthBearer({\n token: {\n secretId: 'AGENT_DEMO_TOKEN', // or: value: 'literal-token'\n userSession: { userId: 'demo-user' },\n },\n})\n```\n\nAn unset secret leaves the middleware inert rather than erroring, so a template\nthat ships this is safe until someone provides the secret. A malformed\n`Authorization` header (no `Bearer ` scheme) throws `InvalidSessionError` in\neither mode.\n\n**`authCookie` options.** `name`, `expiresIn` and `options` are all part of the\nconfig; `options` merges over the defaults `{ httpOnly: true, secure: true,\nsameSite: 'lax', path: '/' }`, so only override what you need. The cookie is\nre-issued after the request only when the session actually changed, which is how\na rolling session extends itself without writing a `Set-Cookie` on every\nresponse.\n\n## Complete Example\n\n```typescript\n// permissions.ts\nimport { pikkuAuth, pikkuPermission } from '#pikku'\n\nexport const isAuthenticated = pikkuAuth(async (_services, session) => !!session)\nexport const isVerified = pikkuAuth(async (_services, session) => !!session?.emailVerified)\n\n// wirings/auth.wiring.ts\nimport { authCookie } from '@pikku/core/middleware'\nimport { addHTTPMiddleware } from '#pikku'\n\naddHTTPMiddleware('*', [\n authCookie({\n name: 'session',\n expiresIn: { value: 30, unit: 'day' },\n options: {},\n }),\n])\n\n// functions/auth.functions.ts\nexport const login = pikkuFunc({\n auth: false,\n func: async ({ jwt, db }, { email, password }, { setSession }) => {\n const user = await db.verifyCredentials(email, password)\n await setSession({ userId: user.id })\n return { token: jwt.sign({ userId: user.id }) }\n },\n})\n\nexport const logout = pikkuFunc({\n func: async ({}, _data, { clearSession }) => {\n await clearSession()\n },\n})\n```\n", "pikku-services/references/audit-wire-service.md": "# Audit Wire Service\n\n`createInvocationAudit` creates a per-request `InvocationAuditLog` that buffers audit events in memory and flushes them as a batch when the function-runner calls `closeWireServices` at the end of the request. If `singletonServices.audit` is not configured (local dev without Fabric), it returns a no-op `DisabledInvocationAudit` — no crash, events are silently dropped.\n\nPair with `createAuditedKysely` to auto-capture every Kysely query as an audit event.\n\n```typescript\n// services.ts\nimport { createInvocationAudit } from '@pikku/core/services'\nimport { createAuditedKysely } from '@pikku/kysely'\n\nexport const createWireServices = pikkuWireServices(async (singletonServices, wire) => {\n const audit = createInvocationAudit(singletonServices.audit, wire)\n const kysely = singletonServices.kysely\n ? createAuditedKysely(singletonServices.kysely, { audit })\n : undefined\n return { audit, ...(kysely ? { kysely } : {}) }\n})\n```\n\nThe `audit` wire service is typed as `AuditLog` (from `@pikku/core`). Functions that emit custom events use it directly:\n\n```typescript\nconst deleteUser = pikkuFunc({\n func: async ({ audit }, { userId }) => {\n // The user identity comes from the wire session — the payload is metadata.\n await audit.write({ type: 'user.deleted', source: 'explicit', metadata: { userId } })\n // ...\n },\n})\n```\n\n`closeWireServices` (called automatically by the function-runner) invokes `audit.close()` → `singletonServices.audit.write(batch)` → platform-specific flush (e.g. CF Queue, libsql INSERT). No manual flushing needed.\n\n> **Fabric note:** Fabric provisions the audit queue and consumer worker automatically. The audit table schema is in `db/sqlite/0003-audit.sql` (starter-template). Run `pikku fabric validate` to confirm the migration is in place.\n", "pikku-services/SKILL.md": "---\nname: pikku-services\ndescription: >-\n Use when setting up dependency injection, creating custom services, or configuring the service\n layer in a Pikku app. Covers pikkuServices (singleton), pikkuWireServices (per-request),\n pikkuServerLifecycle (startup/shutdown hooks), service typing, built-in services, and\n tree-shaking. TRIGGER when: code uses pikkuServices/pikkuWireServices/pikkuServerLifecycle, user\n asks about services.ts, lifecycle.ts, dependency injection, service factories, startup or\n shutdown work, or built-in services (ConsoleLogger, JoseJWTService). DO NOT TRIGGER when: user asks\n about middleware (use pikku-middleware), auth strategies or sessions (use pikku-security),\n permissions (use pikku-permissions), or secrets/variables (use pikku-config).\ninstallGroups: [core]\n---\n\n# Pikku Services (Dependency Injection)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nPikku uses factory functions for dependency injection. Singleton services are created once at startup; wire services are created fresh per request/job/command. See `pikku-concepts` for the core mental model.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See which services existing functions use\npikku info tags --verbose # Understand project organization\n```\n\n## API Reference\n\n### `pikkuServices(factory)` — singleton services (created once at startup)\n\n```typescript\nimport { pikkuServices } from '#pikku'\nimport { ConsoleLogger } from '@pikku/core/services'\nimport { JoseJWTService } from '@pikku/jose'\n\nexport const createSingletonServices = pikkuServices(\n async (config, existingServices?) => {\n // config: your CoreConfig object\n // existingServices: optional, for chaining factories\n const logger = new ConsoleLogger()\n const database = new DatabasePool(config.database)\n await database.connect()\n const jwt = new JoseJWTService(\n async () => [{ id: 'my-key', value: config.jwtSecret }],\n logger\n )\n return { config, logger, database, jwt, books: new BookService() }\n }\n)\n```\n\n### `pikkuWireServices(factory)` — per-request services (fresh per HTTP request, queue job, CLI command, etc.)\n\n```typescript\nimport { pikkuWireServices } from '#pikku'\n\nexport const createWireServices = pikkuWireServices(\n async (singletonServices, wire) => {\n // singletonServices: all singleton services\n // wire: transport context (session, channel, etc.)\n // Pikku merges these with singleton services automatically\n return {\n userSession: createUserSessionService(wire),\n dbTransaction: new DatabaseTransaction(singletonServices.database),\n }\n }\n)\n```\n\n### `pikkuServerLifecycle(hooks)` — startup and shutdown work\n\nA service factory should **construct** services, not run startup side effects. Seeding a database, warming a cache, starting a background consumer or draining a queue belongs in lifecycle hooks, which receive the singleton services after they are built:\n\n```typescript\n// src/lifecycle.ts\nimport { pikkuServerLifecycle } from '@pikku/core'\nimport type { SingletonServices } from '../types/application-types.js'\n\nexport const lifecycle = pikkuServerLifecycle<SingletonServices>({\n beforeStart: async ({ kysely }) => {\n await runMigrations(kysely) // before the port opens\n },\n afterStart: async (services) => {\n await seedDevData(services) // server is accepting traffic\n },\n beforeStop: async ({ queueService }) => {\n await queueService.drain() // services are still alive here\n },\n afterStop: async () => {\n await releaseExternalLock() // services are ALREADY stopped\n },\n})\n```\n\nEvery hook is optional. Order is `beforeStart` → server starts → `afterStart`, then on SIGINT `beforeStop` → services stopped → server stopped → `afterStop`.\n\n**`afterStop` runs after the singleton services have been stopped.** It still receives the services object, but the services inside it are shut down — using one there is a use-after-close bug. Anything that needs a live service goes in `beforeStop`.\n\nExport **exactly one** `pikkuServerLifecycle` from anywhere in `srcDirectories`; the inspector finds it by the wrapper call, so the filename is free (`src/lifecycle.ts` by convention). It must be an exported `const` initialized with a direct call to `pikkuServerLifecycle` — a re-export or a conditional wrapper is invisible to the inspector.\n\n**Only `pikku dev` and `pikku serve` run these hooks.** If you bootstrap your own server (Express, Fastify, uWS, Lambda, Cloudflare, Next.js), no runtime adapter invokes them — put the work in your entrypoint instead.\n\n### Auto-Generated Service Manifest\n\nAfter `npx pikku all`, Pikku generates `.pikku/pikku-services.gen.ts`, a manifest of which services are actually used by wired functions:\n\n```typescript\nexport const requiredSingletonServices = {\n database: true, // used by getUser, deleteUser\n audit: true, // used by deleteUser\n cache: false, // not used by any wired function\n jwt: true, // used by auth middleware\n} as const\n\nexport type RequiredSingletonServices = Pick<\n SingletonServices,\n 'database' | 'audit' | 'jwt'\n> &\n Partial<Omit<SingletonServices, 'database' | 'audit' | 'jwt'>>\n```\n\n## Usage Patterns\n\n### Using Services in Functions\n\n**Every service must be declared in `SingletonServices` (or `Services`) in `application-types.d.ts`.** Never access a service via a body-level cast (`services as typeof services & { myService: MyService }`) — that means the type is missing. Add the import and the field to `SingletonServices`, then destructure inline in the function signature. The inspector emits `SERVICES_NOT_DESTRUCTURED` (`PKU410`) and tree-shaking breaks when the first param is a plain identifier rather than an object pattern. Never `new` a service inside a function — services arrive only via injection.\n\n```typescript\n// ✅ Correct — inline destructure, no cast\nconst getUser = pikkuFunc({\n title: 'Get User',\n func: async ({ db, logger, jwt }, { userId }) => {\n logger.info('Fetching user', { userId })\n return { user: await db.getUser(userId) }\n },\n})\n\n// ❌ Wrong — named param + body cast; inspector warns + tree-shaking breaks\nconst getUser = pikkuFunc({\n func: async (services, { userId }) => {\n const { db } = services as typeof services & { db: DbService }\n // ...\n },\n})\n```\n\n### Services Are Never Optional Inside a Function\n\n**Never write a `if (!service) throw ...` existence guard in a function body.** It is dead code, and it defeats the platform.\n\nOptionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means *\"this may not be created\"*, not *\"this may be missing at call time\"*. A service is optional precisely because **nothing destructures it**, and the generated `requiredSingletonServices` manifest therefore never marks it for creation. The moment any wired function destructures it, Pikku creates it and guarantees it is there.\n\nThe types enforce this rather than merely documenting it. The inspector records the services destructured by every wired `func`, `permissions` **and** `middleware`, and emits them as `RequiredSingletonServices`. The generated function types then default their service parameter to:\n\n```typescript\nexport type WiredSingletonServices = RequiredSingletonServices & SingletonServices\nexport type WiredServices = SecretlessServices<RequiredSingletonServices & Services>\n```\n\nThe `SecretlessServices<...>` wrapper is why `secrets` never appears in a\nfunction's services: it is stripped at the type level, not merely omitted by\nconvention. Read secrets in a service factory or middleware and hand the value\nto a service instead.\n\nso a service that is `foo?: Foo` in `SingletonServices` arrives as a non-optional `Foo` in every function, permission and middleware that uses it. There is nothing to guard against.\n\n```typescript\n// ✅ Correct — destructure and use; creation is guaranteed by the manifest\nconst listThreads = pikkuFunc({\n func: async ({ agentRunService }, { threadId }) => {\n return await agentRunService.getThreadMessages(threadId)\n },\n})\n\n// ❌ Wrong — unreachable guard; signals a misunderstanding of service wiring\nconst listThreads = pikkuFunc({\n func: async ({ agentRunService }, { threadId }) => {\n if (!agentRunService) throw new MissingServiceError('agentRunService')\n return await agentRunService.getThreadMessages(threadId)\n },\n})\n```\n\nIf a service really is conditional at runtime (e.g. an optional integration a deployment may not configure), that is a **configuration** concern: branch on config, or fail fast at startup in `services.ts` — not per-request in every function.\n\n### Dynamic Import Optimization\n\nUse the generated manifest to conditionally import heavy dependencies — only the services actually wired get instantiated:\n\n```typescript\nimport { requiredSingletonServices } from '.pikku/pikku-services.gen.js'\n\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n\n let jwt: JWTService | undefined\n if (requiredSingletonServices.jwt) {\n const { JoseJWTService } = await import('@pikku/jose')\n jwt = new JoseJWTService(keys, logger)\n }\n\n let database: Database | undefined\n if (requiredSingletonServices.database) {\n database = await createDatabase(config.databaseUrl)\n }\n\n return { config, logger, jwt, database }\n})\n```\n\n### Audit Wire Service\n\n`createInvocationAudit` + `createAuditedKysely` add per-request audit buffering that flushes on request close (no-op if `audit` is unconfigured). For the full pattern, no-op behavior, custom-event usage, and Fabric notes, read `references/audit-wire-service.md`.\n\n### Built-in Services\n\n| Service | Package | Purpose |\n| -------------------------- | ---------------------- | -------------------------------- |\n| `ConsoleLogger` | `@pikku/core/services` | Console-based logging |\n| `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |\n| `LocalSecretService` | `@pikku/core/services` | Local development secrets |\n| `LocalVariablesService` | `@pikku/core/services` | Local environment variables |\n| `PinoLogger` | `@pikku/pino` | Structured logging via Pino |\n| `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |\n| `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |\n\n## Complete Example\n\n```typescript\n// services.ts\nimport { pikkuServices, pikkuWireServices } from '#pikku'\nimport { ConsoleLogger } from '@pikku/core/services'\nimport { JoseJWTService } from '@pikku/jose'\n\n// Custom service\nclass TodoStore {\n private todos: Map<string, Todo> = new Map()\n async create(title: string, priority: string) {\n const todo = { id: crypto.randomUUID(), title, priority, completed: false }\n this.todos.set(todo.id, todo)\n return todo\n }\n async get(id: string) { return this.todos.get(id) }\n async list() { return [...this.todos.values()] }\n async delete(id: string) { this.todos.delete(id) }\n}\n\nexport const createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const jwt = new JoseJWTService(\n async () => [{ id: 'my-key', value: config.jwtSecret }],\n logger\n )\n return {\n config,\n logger,\n jwt,\n secrets: new LocalSecretService(),\n variables: new LocalVariablesService(),\n todoStore: new TodoStore(),\n }\n})\n\nexport const createWireServices = pikkuWireServices(\n async (singletonServices, wire) => ({\n scopedLogger: new ScopedLogger(wire.session?.userId),\n })\n)\n\n// functions/todos.functions.ts — services are auto-injected\nexport const createTodo = pikkuFunc({\n title: 'Create Todo',\n func: async ({ todoStore, logger }, { title, priority }) => {\n const todo = await todoStore.create(title, priority)\n logger.info('Created todo', { id: todo.id })\n return { todo }\n },\n})\n```\n", "pikku-software-archaeology/README.md": "# pikku-software-archaeology\n\nReverse-engineers an existing repository into a **Product Blueprint**: the product intelligence hidden inside an implementation (domains, entities, commands, queries, events, policies, workflows, invariants, integrations, gaps), extracted as schema-validated JSON that a generator — in our case Pikku — can rebuild from.\n\n```\nExisting Repository → pikku-software-archaeology → .knowledge/ blueprint → new Pikku application\n```\n\nThis is **not** a code indexer or doc generator. It extracts _intent over implementation_: `POST /api/users/:id/status` becomes the command `ActivateUser`; three scattered `if (inv.user_id !== req.user.id)` checks become one `InvoiceOwnerOnly` policy with three `enforcedAt` citations.\n\n## Design decision: the AI is the parser\n\nThere is deliberately **no scanner/AST tooling** in this skill. Static extraction is brittle and per-language (the first prototype's regex scanner broke before it ran once); the analyzing model already reads every language — JS, TS, Ruby, Python, PHP, Go — follows indirection, and understands intent. Determinism lives in the **output contract** instead: fixed file names, schema-validated shapes, sorted unordered collections (sequence-bearing arrays keep their observed order), and stable concept names, all enforced by a dumb JSON validator (`scripts/validate.mjs`). The audit is expensive; that's the trade we chose.\n\n## How to run it\n\nIn Claude Code, from (or pointing at) the target repo:\n\n> Use the pikku-software-archaeology skill to extract a product blueprint from /path/to/repo\n\nThe agent then:\n\n1. **Surveys** the repo (manifests, entry points, routes, jobs, webhooks, schema, config, TODO/HACK markers) — facts only.\n2. **Excavates the test suite** — `describe`/`it` names become workflow scenarios; assertions confirm policies and upgrade confidence; rules that exist _only_ in tests are captured.\n3. **Extracts** through twelve lenses (domains, entities, commands, …) per the pipeline in `SKILL.md`. Large repos fan out subagents per lens and merge.\n4. **Cross-checks and validates**:\n ```bash\n node .claude/skills/pikku-software-archaeology/scripts/validate.mjs <repo>/.knowledge\n ```\n The validator checks every file against `references/blueprint.schema.json` plus referential integrity across files (commands reference defined domains, api surfaces map to defined commands/queries, events have producers, …).\n5. Writes `blueprint.md`, the human synthesis.\n\nOutput lands in `<repo>/.knowledge/` — 14 core JSON files + `blueprint.md` (see `SKILL.md` for the full listing). Repos with a frontend and/or non-HTTP consumer channels also get an **optional consumer-surface layer**: `interfaces.json` (every way the product is used — web UI, CLI, MCP server for AI agents, OpenAPI/REST, SDK, realtime, webhooks) plus `frontend.json` / `frontend-routes.json` / `frontend-components.json`. The frontend component inventory's `rebuild` field is the key output: it separates trivially-rebuildable standard components from the **custom-logic** pieces (charts, complex tables, editors) that must be carefully ported. Backend-only repos omit these and the validator does not complain.\n\n### Incremental re-analysis\n\nConcept names are the stable IDs. On re-run after code changes, re-extract only the affected lens/domain, diff against the existing `.knowledge/`, and leave unrelated entries verbatim. Unordered collections are sorted so diffs stay reviewable; sequence-bearing arrays stay in observed order.\n\n## How Pikku consumes the blueprint\n\nFull mapping table in `references/pikku-mapping.md`. Summary: entities → Kysely migrations + Zod schemas; commands/queries → `pikkuFunc`s; api surfaces → `wireHTTP`; policies → shared permission functions (collapsing duplicated legacy checks); system workflows → `wireScheduler`/`wireQueueWorker`/`pikkuWorkflowFunc`; integrations → injected services with `defineSecret`/`defineCredential`; test-derived scenarios → `pikkuUserFlow` stories / e2e tests. Humans resolve `migration.json.decisionsNeeded` before any generation starts.\n\n## How uncertainty is represented\n\nEvery extracted concept carries:\n\n```json\n{\n \"evidence\": [\n {\n \"file\": \"controllers/invoices.js\",\n \"lines\": \"52\",\n \"note\": \"guard: only drafts editable\"\n }\n ],\n \"confidence\": \"high\"\n}\n```\n\n- **high** — the behavior itself is in the cited code/schema/test. Generates directly.\n- **medium** — inferred from converging signals. Generates with a review marker.\n- **low** — plausible reconstruction. Never auto-generated; surfaced for human review.\n\nTwo further distinctions keep facts and guesses separate:\n\n- `events[].explicit: false` — the event was _reconstructed_ from side-effect clusters (email + status flip), not emitted by the code.\n- Comments/docs vs code: comments describe intent, code describes behavior. Disagreements are recorded as the code's behavior plus a `gaps.json` entry.\n\n## Repo layout\n\n```\npikku-software-archaeology/\n├── SKILL.md # skill definition + extraction pipeline\n├── README.md # this file\n├── references/\n│ ├── blueprint.schema.json # the output contract (JSON Schema)\n│ └── pikku-mapping.md # blueprint → Pikku primitives\n└── scripts/\n └── validate.mjs # schema + cross-file validation (node, no deps)\n```\n", "pikku-software-archaeology/references/blueprint.schema.json": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"title\": \"Product Blueprint (.knowledge/) contract\",\n \"description\": \"One schema per output file, keyed by filename under the `files` map. Every extracted concept carries evidence[] and confidence. Facts (observed in code) and inferences (reconstructed intent) must never be merged silently: confidence expresses how directly the evidence supports the claim.\",\n \"$defs\": {\n \"evidence\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"file\", \"lines\"],\n \"properties\": {\n \"file\": { \"type\": \"string\", \"description\": \"repo-relative path\" },\n \"lines\": { \"type\": \"string\", \"description\": \"e.g. '42' or '42-58'\" },\n \"note\": { \"type\": \"string\", \"description\": \"what this location shows\" }\n }\n }\n },\n \"confidence\": {\n \"type\": \"string\",\n \"enum\": [\"high\", \"medium\", \"low\"],\n \"description\": \"high = behavior directly observed in code/schema; medium = strong inference from multiple signals; low = plausible guess, needs human confirmation\"\n },\n \"conceptName\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Z][A-Za-z0-9]*$\",\n \"description\": \"PascalCase domain-language name (SendInvoice, InvoicePaid), never a route or filename\"\n }\n },\n \"files\": {\n \"product.json\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"purpose\", \"actors\", \"capabilities\", \"terminology\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"purpose\": { \"type\": \"string\", \"description\": \"1-3 sentences: what problem, for whom\" },\n \"targetUsers\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"actors\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"description\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"human\", \"system\", \"external\"] },\n \"description\": { \"type\": \"string\" }\n }\n }\n },\n \"capabilities\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" } },\n \"terminology\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"term\", \"meaning\"],\n \"properties\": { \"term\": { \"type\": \"string\" }, \"meaning\": { \"type\": \"string\" } }\n }\n },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n },\n \"domains.json\": {\n \"type\": \"object\",\n \"required\": [\"domains\"],\n \"properties\": {\n \"domains\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"description\", \"entities\", \"commands\", \"queries\", \"events\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"entities\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"commands\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"queries\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"events\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"policies\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" }, \"description\": \"policy names from policies.json owned by this domain\" },\n \"sourcePaths\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"where this domain currently lives (usually scattered)\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"entities.json\": {\n \"type\": \"object\",\n \"required\": [\"entities\"],\n \"properties\": {\n \"entities\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"description\", \"attributes\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"attributes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"type\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"type\": { \"type\": \"string\" },\n \"required\": { \"type\": \"boolean\" },\n \"notes\": { \"type\": \"string\" }\n }\n }\n },\n \"relationships\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"kind\", \"target\"],\n \"properties\": {\n \"kind\": { \"type\": \"string\", \"enum\": [\"belongs-to\", \"has-many\", \"has-one\", \"references\", \"many-to-many\"] },\n \"target\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" }\n }\n }\n },\n \"states\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"transitions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"from\", \"to\", \"trigger\"],\n \"properties\": {\n \"from\": { \"type\": \"string\" },\n \"to\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\", \"description\": \"command or event name that causes it\" }\n }\n }\n },\n \"ownership\": { \"type\": \"string\", \"description\": \"which actor/tenant owns rows of this entity\" },\n \"constraints\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"commands.json\": {\n \"type\": \"object\",\n \"required\": [\"commands\"],\n \"properties\": {\n \"commands\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"actor\", \"input\", \"preconditions\", \"effects\", \"eventsProduced\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\", \"description\": \"imperative VerbNoun: ApproveInvoice\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\", \"description\": \"how it is invoked today (route, job, webhook, cron)\" },\n \"input\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"type\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"type\": { \"type\": \"string\" },\n \"required\": { \"type\": \"boolean\" }\n }\n }\n },\n \"preconditions\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"effects\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" } },\n \"eventsProduced\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"policies\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"policy names from policies.json enforced here\" },\n \"currentImplementation\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"file paths implementing it today\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"queries.json\": {\n \"type\": \"object\",\n \"required\": [\"queries\"],\n \"properties\": {\n \"queries\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"actor\", \"description\", \"returns\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\", \"description\": \"GetX / ListX / SearchX\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"actor\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" },\n \"input\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"type\"],\n \"properties\": { \"name\": { \"type\": \"string\" }, \"type\": { \"type\": \"string\" }, \"required\": { \"type\": \"boolean\" } }\n }\n },\n \"returns\": { \"type\": \"string\" },\n \"scoping\": { \"type\": \"string\", \"description\": \"tenancy/visibility rule applied (e.g. 'rows owned by caller only')\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"events.json\": {\n \"type\": \"object\",\n \"required\": [\"events\"],\n \"properties\": {\n \"events\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"domain\", \"description\", \"producedBy\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\", \"description\": \"past-tense business fact: InvoicePaid. No technical events (ButtonClicked).\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"producedBy\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" }, \"description\": \"command/workflow names\" },\n \"consumedBy\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"what reacts today (email send, status flip, webhook out)\" },\n \"payloadHints\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"explicit\": { \"type\": \"boolean\", \"description\": \"true if the code emits a real event; false if reconstructed from inline side-effects\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"policies.json\": {\n \"type\": \"object\",\n \"required\": [\"policies\"],\n \"properties\": {\n \"policies\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"rule\", \"type\", \"domain\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"rule\": { \"type\": \"string\", \"description\": \"plain-language statement: 'Only the invoice owner can send it'\" },\n \"type\": { \"type\": \"string\", \"enum\": [\"authorization\", \"validation\", \"state\", \"business-constraint\", \"rate-limit\"] },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedAt\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"every code location enforcing it — multiple locations = divergence risk, note in gaps.json\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"workflows.json\": {\n \"type\": \"object\",\n \"required\": [\"workflows\"],\n \"properties\": {\n \"workflows\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"actor\", \"trigger\", \"steps\", \"result\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"user\", \"admin\", \"system\"] },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\" },\n \"steps\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" } },\n \"result\": { \"type\": \"string\" },\n \"commandsInvolved\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"eventsInvolved\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"schedule\": { \"type\": \"string\", \"description\": \"cron expression for system workflows, if any\" },\n \"scenarios\": {\n \"type\": \"array\",\n \"description\": \"concrete use cases, primarily excavated from the test suite (describe/it names, fixtures, assertions). A scenario sourced from a test is high-confidence by definition.\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"outcome\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"given\": { \"type\": \"string\" },\n \"when\": { \"type\": \"string\" },\n \"outcome\": { \"type\": \"string\" },\n \"fromTest\": { \"type\": \"string\", \"description\": \"test file path + test name, when sourced from a test\" }\n }\n }\n },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"api.json\": {\n \"type\": \"object\",\n \"required\": [\"surfaces\"],\n \"properties\": {\n \"surfaces\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"kind\", \"path\", \"auth\", \"mapsTo\", \"evidence\"],\n \"properties\": {\n \"kind\": { \"type\": \"string\", \"enum\": [\"rest\", \"graphql\", \"rpc\", \"webhook-in\", \"webhook-out\", \"page\", \"cli\", \"websocket\", \"sse\"] },\n \"method\": { \"type\": \"string\" },\n \"path\": { \"type\": \"string\" },\n \"auth\": { \"type\": \"string\", \"description\": \"none | session | jwt | api-key | signature | capability-url | ...\" },\n \"mapsTo\": {\n \"type\": \"object\",\n \"required\": [\"type\", \"name\"],\n \"properties\": {\n \"type\": { \"type\": \"string\", \"enum\": [\"command\", \"query\", \"event-ingress\"], \"description\": \"command/query for normal surfaces. event-ingress ONLY for surfaces that relay an external event without a domain command of their own; its name must be an events.json event. A webhook whose handler changes state maps to a command (usual case).\" },\n \"name\": { \"$ref\": \"#/$defs/conceptName\" }\n }\n },\n \"notes\": { \"type\": \"string\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n }\n }\n },\n \"integrations.json\": {\n \"type\": \"object\",\n \"required\": [\"integrations\"],\n \"properties\": {\n \"integrations\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"category\", \"purpose\", \"direction\", \"importance\", \"replacementDifficulty\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"category\": { \"type\": \"string\", \"description\": \"payments | email | auth | storage | analytics | llm | sms | ...\" },\n \"purpose\": { \"type\": \"string\" },\n \"dataExchanged\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"direction\": { \"type\": \"string\", \"enum\": [\"outbound\", \"inbound\", \"both\"] },\n \"importance\": { \"type\": \"string\", \"enum\": [\"critical\", \"important\", \"peripheral\"] },\n \"replacementDifficulty\": { \"type\": \"string\", \"enum\": [\"trivial\", \"moderate\", \"hard\"] },\n \"configVia\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"env vars / secrets used\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"architecture.json\": {\n \"type\": \"object\",\n \"required\": [\"components\", \"datastores\"],\n \"properties\": {\n \"components\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"responsibility\", \"evidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"frontend\", \"api\", \"worker\", \"job\", \"webhook-handler\", \"cli\", \"service\", \"proxy\", \"other\"] },\n \"responsibility\": { \"type\": \"string\" },\n \"dependsOn\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"runtime\": { \"type\": \"string\", \"description\": \"how it runs today (process, cron, lambda, ...)\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n },\n \"datastores\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"evidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\" },\n \"usedBy\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"notes\": { \"type\": \"string\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n },\n \"notes\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"deployment constraints worth preserving (ports, raw-body routes, ordering)\" }\n }\n },\n \"invariants.json\": {\n \"type\": \"object\",\n \"required\": [\"invariants\"],\n \"properties\": {\n \"invariants\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"statement\", \"domain\", \"enforcedBy\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"statement\": { \"type\": \"string\", \"description\": \"must ALWAYS hold: 'Invoice numbers are sequential per user with no gaps'\" },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedBy\": { \"type\": \"string\", \"description\": \"db-constraint | code-guard | convention | nothing (!)\" },\n \"atRiskBecause\": { \"type\": \"string\", \"description\": \"why current enforcement is fragile, if it is\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"gaps.json\": {\n \"type\": \"object\",\n \"required\": [\"gaps\"],\n \"properties\": {\n \"gaps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"problem\", \"kind\", \"impact\", \"recommendation\", \"evidence\"],\n \"properties\": {\n \"problem\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"incomplete-feature\", \"todo\", \"duplication\", \"bug\", \"hack\", \"dead-code\", \"unclear-ownership\", \"architecture\", \"security\", \"open-product-decision\"] },\n \"impact\": { \"type\": \"string\" },\n \"recommendation\": { \"type\": \"string\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" }\n }\n }\n }\n }\n },\n \"migration.json\": {\n \"type\": \"object\",\n \"required\": [\"mappings\", \"decisionsNeeded\"],\n \"properties\": {\n \"mappings\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"current\", \"future\", \"recommendation\"],\n \"properties\": {\n \"current\": { \"type\": \"array\", \"minItems\": 1, \"items\": { \"type\": \"string\" }, \"description\": \"file paths in the existing repo\" },\n \"future\": {\n \"type\": \"object\",\n \"required\": [\"domain\"],\n \"properties\": {\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"concepts\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"commands/queries/events/entities this code becomes\" }\n }\n },\n \"recommendation\": { \"type\": \"string\" }\n }\n }\n },\n \"dropped\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"path\", \"reason\"],\n \"properties\": { \"path\": { \"type\": \"string\", \"description\": \"file path; for sub-file drops use 'path (symbolName)' when part of a file survives\" }, \"reason\": { \"type\": \"string\" } }\n }\n },\n \"decisionsNeeded\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"question\"],\n \"properties\": {\n \"question\": { \"type\": \"string\" },\n \"options\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"blockedConcepts\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n }\n }\n }\n }\n },\n \"interfaces.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"Every way the product is CONSUMED, one entry per channel. The web UI is one channel (detailed further in frontend*.json); CLI, MCP server (for AI agents), OpenAPI/REST, generated SDK, realtime, and webhooks are others. Answers 'who can drive this product and how'.\",\n \"required\": [\"interfaces\"],\n \"properties\": {\n \"interfaces\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"kind\", \"audience\", \"purpose\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"web-ui\", \"cli\", \"mcp\", \"openapi-rest\", \"graphql\", \"sdk\", \"websocket-realtime\", \"webhook-in\", \"webhook-out\", \"email\", \"other\"] },\n \"audience\": { \"type\": \"string\", \"enum\": [\"human\", \"developer\", \"ai-agent\", \"external-system\", \"internal\"] },\n \"purpose\": { \"type\": \"string\" },\n \"surfaceCount\": { \"type\": \"number\", \"description\": \"how many ops/tools/commands/routes this channel exposes\" },\n \"generated\": { \"type\": \"boolean\", \"description\": \"true if generated from another source (OpenAPI from routes, typed SDK, MCP tools from funcs) rather than hand-written\" },\n \"domainsServed\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/$defs/conceptName\" } },\n \"status\": { \"type\": \"string\", \"enum\": [\"complete\", \"partial\", \"stub\", \"deprecated\"] },\n \"notes\": { \"type\": \"string\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"frontend.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"Web-UI app-level shape. Names the framework/router/styling/data/auth so the rebuild can weigh their tradeoffs (e.g. TanStack Start + better-auth + Mantine).\",\n \"required\": [\"framework\", \"styling\", \"dataLayer\", \"auth\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"framework\": { \"type\": \"string\", \"description\": \"e.g. TanStack Start, Next.js, Remix, Vite SPA\" },\n \"rendering\": { \"type\": \"string\", \"enum\": [\"spa\", \"ssr\", \"ssg\", \"streaming-ssr\", \"mixed\"] },\n \"router\": { \"type\": \"string\", \"description\": \"e.g. TanStack Router (file-based), React Router\" },\n \"styling\": { \"type\": \"string\", \"description\": \"the design system, e.g. Mantine, Tailwind, MUI, CSS modules\" },\n \"designSystemConsistency\": { \"type\": \"string\", \"enum\": [\"single-system\", \"mostly-consistent\", \"mixed\", \"ad-hoc\"], \"description\": \"how uniformly ONE component/theme system is used — the rebuild target is 'everything Mantine', so divergence is porting work\" },\n \"designFindings\": {\n \"type\": \"array\",\n \"description\": \"Concrete broken/inconsistent design patterns observed across the UI. Each is a specific, cited observation of a consistency defect — not a taste opinion. These become the design recommendations in the second-opinion report.\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"pattern\", \"observation\", \"recommendation\", \"evidence\"],\n \"properties\": {\n \"pattern\": {\n \"type\": \"string\",\n \"enum\": [\n \"interaction-inconsistency\",\n \"theming-not-tokenized\",\n \"cross-page-inconsistency\",\n \"component-duplication\",\n \"spacing-typography-scale\",\n \"design-system-bypass\",\n \"accessibility\",\n \"responsive\",\n \"other\"\n ],\n \"description\": \"interaction-inconsistency = same job done different ways (modal here, drawer there; inconsistent confirm dialogs). theming-not-tokenized = hardcoded colors/spacing/fonts instead of theme tokens/variables. cross-page-inconsistency = the same element (button/header/card) styled differently across pages. component-duplication = several near-identical components for one purpose. spacing-typography-scale = ad-hoc magic numbers off any scale. design-system-bypass = raw HTML/CSS where a design-system component exists. accessibility / responsive = a11y or breakpoint defects.\"\n },\n \"observation\": { \"type\": \"string\", \"description\": \"what is inconsistent, with concrete examples (e.g. 'Add-site uses a Drawer, Edit-site uses a Modal for the same task')\" },\n \"impact\": { \"type\": \"string\", \"description\": \"what it does to the user (feels unpolished/confusing) or to maintenance (a color change means hunting every file)\" },\n \"recommendation\": { \"type\": \"string\", \"description\": \"the fix — usually standardize on one pattern, move values to theme tokens, or extract one shared component\" },\n \"severity\": { \"type\": \"string\", \"enum\": [\"minor\", \"worth-fixing\", \"serious\"] },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n },\n \"stateManagement\": { \"type\": \"string\" },\n \"dataLayer\": { \"type\": \"string\", \"description\": \"how the UI talks to the backend: e.g. pikku-react-query, REST fetch helpers, tRPC, GraphQL client\" },\n \"auth\": { \"type\": \"string\", \"description\": \"client auth mechanism: e.g. better-auth, next-auth, custom JWT\" },\n \"buildTool\": { \"type\": \"string\" },\n \"i18n\": { \"type\": \"string\", \"description\": \"internationalization approach, or 'none'\" },\n \"notes\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n },\n \"frontend-routes.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"The page/route tree — what a user can navigate to and do, mapped back to the data (queries/commands) each route uses and the components it renders.\",\n \"required\": [\"routes\"],\n \"properties\": {\n \"routes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"path\", \"purpose\", \"evidence\"],\n \"properties\": {\n \"path\": { \"type\": \"string\", \"description\": \"URL path, e.g. /app/sites/:id\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"page\", \"layout\", \"index\", \"modal-or-drawer\", \"redirect\"] },\n \"purpose\": { \"type\": \"string\", \"description\": \"what the user does/sees here, in product terms\" },\n \"auth\": { \"type\": \"string\", \"description\": \"none | authenticated | admin | role:xyz\" },\n \"dataFrom\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"query/command names (from queries.json/commands.json) this route reads/calls\" },\n \"usesComponents\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"component names from frontend-components.json\" },\n \"userFlows\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"workflows.json (kind=user) names this route participates in\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n },\n \"frontend-components.json\": {\n \"x-optional\": true,\n \"type\": \"object\",\n \"description\": \"Component inventory. The load-bearing field is `rebuild`: it separates components that are trivially rebuildable in the target design system (Mantine) from those carrying bespoke logic that must be carefully PORTED (custom charts, complex tables, canvas, drag/drop). That split is the frontend's real migration cost.\",\n \"required\": [\"components\"],\n \"properties\": {\n \"components\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"role\", \"rebuild\", \"evidence\", \"confidence\"],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"role\": { \"type\": \"string\", \"enum\": [\"layout\", \"navigation\", \"presentational\", \"feature\", \"form\", \"data-display\", \"chart\", \"table\", \"overlay\", \"provider\", \"other\"] },\n \"purpose\": { \"type\": \"string\" },\n \"reuse\": { \"type\": \"string\", \"enum\": [\"shared\", \"one-off\"], \"description\": \"used across features vs single-use\" },\n \"rebuild\": {\n \"type\": \"string\",\n \"enum\": [\"mantine-standard\", \"mantine-composition\", \"custom-style\", \"custom-logic\"],\n \"description\": \"mantine-standard = maps 1:1 to a Mantine component (trivial); mantine-composition = built from Mantine primitives (straightforward); custom-style = diverges visually from the design system (normalize to Mantine); custom-logic = bespoke behavior (chart/table/canvas/drag/editor) that must be PORTED, not re-skinned\"\n },\n \"customLogic\": { \"type\": \"string\", \"description\": \"REQUIRED when rebuild=custom-logic: what the bespoke behavior is and why a stock component can't replace it\" },\n \"dependencies\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"notable libs it pulls in (charting/table/editor) — replacement considerations for the port\" },\n \"usedBy\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"routes/components that use it\" },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n }\n }\n }\n }\n}\n", "pikku-software-archaeology/references/pikku-mapping.md": "# How Pikku Consumes a Product Blueprint\n\nThe `.knowledge/` blueprint is designed so each concept maps onto exactly one Pikku primitive. A generator (or an agent following `pikku-feature`) walks the JSON files in this order:\n\n| Blueprint source | Pikku target |\n| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `entities.json` attributes + relationships + constraints | Kysely migrations + generated `DB` types; Zod schemas per entity |\n| `entities.json` states/transitions | a `status` column + transition guards inside the owning commands (or a state-machine helper) |\n| `commands.json` | `pikkuFunc` / `pikkuSessionlessFunc` with `input:` Zod schema built from `input[]`; `preconditions` become guard clauses; name is the camelCased command name (`SendInvoice` → `sendInvoice`) |\n| `queries.json` | `pikkuFunc` reads; `scoping` becomes the mandatory `WHERE` / session filter |\n| `events.json` | EventHub topics (realtime) or queue messages; `consumedBy` become `wireQueueWorker` handlers — implicit events (`explicit: false`) get promoted to real emissions |\n| `policies.json` (authorization) | Pikku `permissions` / middleware; one policy = one named permission function, wired everywhere `enforcedAt` listed — this collapses duplicated legacy checks into a single definition |\n| `policies.json` (validation) | Zod schema refinements on the command's `input` |\n| `workflows.json` kind=user | frontend flows + the commands they chain |\n| `workflows.json` kind=system, with `schedule` | `wireScheduler` entries |\n| `workflows.json` multi-step / checkpointing | `pikkuWorkflowFunc` with one `workflow.do(...)` step per blueprint step |\n| `workflows.json` `scenarios[]` | **`pikkuUserFlow` stories — this is the canonical target.** Each scenario's given/when/outcome maps 1:1 onto a user-flow step sequence; group scenarios by their workflow into one flow per journey. Only scenarios with no user-facing surface (pure system workflows: cron sweeps, webhook ingest) fall back to API/e2e tests |\n| `api.json` | `wireHTTP` routes: keep `path`+`method` for compatibility, point at the mapped command/query func; `auth: none`/capability-URL surfaces get `auth: false` |\n| `api.json` kind=webhook-in | `wireHTTP` with `auth: false` + signature-verification middleware from the integration |\n| `integrations.json` | services in `services.ts` (constructor-injected classes); `configVia` env vars become `defineSecret` / config; per-user credentials become `defineCredential` |\n| `architecture.json` notes | deployment config (ports, raw-body routes, proxy expectations) |\n| `invariants.json` enforcedBy=db-constraint | migration constraints (UNIQUE, CHECK, FK) |\n| `invariants.json` enforcedBy=code-guard/nothing | guard clauses + a test each; `atRiskBecause` entries get a hardening task |\n| `gaps.json` | excluded from generation; `open-product-decision` + `migration.json.decisionsNeeded` go to a human BEFORE generation starts |\n| `migration.json.mappings` | the work plan: one mapping = one migration slice |\n| `interfaces.json` kind=cli | `wireCLI` entrypoints — the CLI commands are the same funcs the routes expose |\n| `interfaces.json` kind=mcp | `wireMCP` — each MCP tool IS a `pikkuFunc` (reuse the command/query funcs; don't author tool duplicates) |\n| `interfaces.json` kind=openapi-rest / sdk | generated, not hand-written: the OpenAPI spec + typed client SDK fall out of the `wireHTTP` routes + codegen |\n| `interfaces.json` kind=websocket-realtime | `pikku-realtime` EventHub topics / channels |\n| `frontend.json` | `apps/app` (TanStack Start) shell: router, `@pikku/mantine` theme, `pikku-react-query` data layer, `better-auth` client — the target stack the legacy UI is rebuilt onto |\n| `frontend-routes.json` | TanStack Router routes under `apps/app/src/routes/**` (thin data containers calling `usePikkuQuery`); `dataFrom` names become the generated hooks; subpath routes for rich detail views |\n| `frontend-components.json` rebuild=`mantine-standard`/`mantine-composition` | components in `packages/components` composed from `@pikku/mantine` — the trivial/straightforward bulk |\n| `frontend-components.json` rebuild=`custom-logic` | the PORT list — each becomes a `packages/components` component that reimplements the bespoke behavior (chart/table/editor); its `dependencies` inform whether the lib is kept or replaced. These are the frontend's real work items |\n| `frontend-components.json` rebuild=`custom-style` | normalize to Mantine/theme tokens; usually deleted-and-recomposed, not ported |\n\n## Order of generation\n\n1. Human resolves `decisionsNeeded`.\n2. Entities → migrations + types.\n3. Policies → permission functions (before commands, so commands can reference them).\n4. Commands + queries → funcs; api.json → wirings.\n5. Events → topics/queues; system workflows → schedulers/workers/workflows.\n6. Scenarios → tests. Run them against the new implementation; they encode the legacy behavior worth preserving.\n\n## Uncertainty handling\n\n- `confidence: high` concepts generate directly.\n- `confidence: medium` concepts generate, but are listed for review in the pre-generation report — id, evidence summary, and what is uncertain — rather than carrying a marker comment in the generated code. The report is the review surface; the generated code stays clean.\n- `confidence: low` concepts are NOT generated automatically — they surface in the pre-generation review along with `decisionsNeeded`.\n", "pikku-software-archaeology/scripts/validate.mjs": "#!/usr/bin/env node\n// Validates a .knowledge/ blueprint directory against references/blueprint.schema.json,\n// then runs cross-file referential checks (does every command's domain exist, does every\n// api surface map to a real command/query, ...). Exit 0 = valid, 1 = errors.\n//\n// Usage: node validate.mjs <path-to-.knowledge-dir>\n\nimport { readFileSync, existsSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst schemaDoc = JSON.parse(readFileSync(join(here, '..', 'references', 'blueprint.schema.json'), 'utf8'));\n\nconst dir = process.argv[2];\nif (!dir) { console.error('usage: node validate.mjs <.knowledge dir>'); process.exit(2); }\n\nconst errors = [];\nconst warnings = [];\n\n// --- minimal JSON-Schema-subset validator (type, required, properties, items, enum, minItems, pattern, $ref -> $defs) ---\nfunction resolveRef(ref) {\n const m = /^#\\/\\$defs\\/(\\w+)$/.exec(ref);\n if (!m || !schemaDoc.$defs[m[1]]) throw new Error(`unresolvable $ref ${ref}`);\n return schemaDoc.$defs[m[1]];\n}\n\nfunction check(value, schema, path) {\n if (schema.$ref) schema = { ...resolveRef(schema.$ref), ...schema, $ref: undefined };\n if (schema.enum && !schema.enum.includes(value)) {\n errors.push(`${path}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}`);\n return;\n }\n const t = schema.type;\n if (t === 'object') {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n errors.push(`${path}: expected object`); return;\n }\n for (const req of schema.required || []) {\n if (!(req in value)) errors.push(`${path}: missing required field \"${req}\"`);\n }\n for (const [k, v] of Object.entries(value)) {\n if (schema.properties?.[k]) check(v, schema.properties[k], `${path}.${k}`);\n }\n } else if (t === 'array') {\n if (!Array.isArray(value)) { errors.push(`${path}: expected array`); return; }\n if (schema.minItems && value.length < schema.minItems) {\n errors.push(`${path}: needs at least ${schema.minItems} item(s), has ${value.length}`);\n }\n if (schema.items) value.forEach((v, i) => check(v, schema.items, `${path}[${i}]`));\n } else if (t === 'string') {\n if (typeof value !== 'string') { errors.push(`${path}: expected string`); return; }\n if (schema.pattern && !new RegExp(schema.pattern).test(value)) {\n errors.push(`${path}: \"${value}\" does not match ${schema.pattern}`);\n }\n } else if (t === 'boolean' && typeof value !== 'boolean') {\n errors.push(`${path}: expected boolean`);\n } else if (t === 'number' && typeof value !== 'number') {\n errors.push(`${path}: expected number`);\n }\n}\n\n// --- load + per-file validation ---\n// Files marked `x-optional` (the frontend layer) only validate when present, so a\n// backend-only repo does not fail for lacking them.\nconst docs = {};\nfor (const [filename, fileSchema] of Object.entries(schemaDoc.files)) {\n const p = join(dir, filename);\n if (!existsSync(p)) {\n if (!fileSchema['x-optional']) errors.push(`${filename}: missing`);\n continue;\n }\n try {\n docs[filename] = JSON.parse(readFileSync(p, 'utf8'));\n } catch (e) {\n errors.push(`${filename}: invalid JSON (${e.message})`); continue;\n }\n check(docs[filename], fileSchema, filename);\n}\nif (!existsSync(join(dir, 'blueprint.md'))) errors.push('blueprint.md: missing');\n\n// --- cross-file referential checks ---\nif (docs['domains.json'] && docs['commands.json']) {\n const domains = new Set((docs['domains.json'].domains || []).map((d) => d.name));\n const commandNames = new Set((docs['commands.json'].commands || []).map((c) => c.name));\n const queryNames = new Set((docs['queries.json']?.queries || []).map((q) => q.name));\n const eventNames = new Set((docs['events.json']?.events || []).map((e) => e.name));\n\n const wantDomain = (owner, d) => {\n if (d && !domains.has(d)) errors.push(`${owner}: domain \"${d}\" not defined in domains.json`);\n };\n for (const c of docs['commands.json'].commands || []) {\n wantDomain(`commands.json:${c.name}`, c.domain);\n for (const ev of c.eventsProduced || []) {\n if (!eventNames.has(ev)) warnings.push(`commands.json:${c.name} produces \"${ev}\" which is not in events.json`);\n }\n }\n for (const q of docs['queries.json']?.queries || []) wantDomain(`queries.json:${q.name}`, q.domain);\n for (const e of docs['entities.json']?.entities || []) wantDomain(`entities.json:${e.name}`, e.domain);\n for (const ev of docs['events.json']?.events || []) wantDomain(`events.json:${ev.name}`, ev.domain);\n\n for (const s of docs['api.json']?.surfaces || []) {\n const { type, name } = s.mapsTo || {};\n if (type === 'command' && !commandNames.has(name)) errors.push(`api.json:${s.method || ''} ${s.path}: maps to unknown command \"${name}\"`);\n if (type === 'query' && !queryNames.has(name)) errors.push(`api.json:${s.method || ''} ${s.path}: maps to unknown query \"${name}\"`);\n if (type === 'event-ingress' && !eventNames.has(name)) errors.push(`api.json:${s.method || ''} ${s.path}: event-ingress maps to unknown event \"${name}\" (state-changing webhooks should map to a command instead)`);\n }\n // every domain's listed concepts should exist\n for (const d of docs['domains.json'].domains || []) {\n for (const c of d.commands || []) if (!commandNames.has(c)) warnings.push(`domains.json:${d.name}: lists command \"${c}\" not in commands.json`);\n for (const q of d.queries || []) if (!queryNames.has(q)) warnings.push(`domains.json:${d.name}: lists query \"${q}\" not in queries.json`);\n for (const e of d.events || []) if (!eventNames.has(e)) warnings.push(`domains.json:${d.name}: lists event \"${e}\" not in events.json`);\n const policyNames = new Set((docs['policies.json']?.policies || []).map((p) => p.name));\n for (const p of d.policies || []) if (!policyNames.has(p)) warnings.push(`domains.json:${d.name}: lists policy \"${p}\" not in policies.json`);\n }\n // commands with no policies and no preconditions are suspicious for mutating ops\n for (const c of docs['commands.json'].commands || []) {\n if (!(c.policies || []).length && !(c.preconditions || []).length) {\n warnings.push(`commands.json:${c.name}: no policies or preconditions — really unguarded, or missed extraction?`);\n }\n }\n}\n\n// --- frontend layer cross-checks (only when the optional frontend files exist) ---\nif (docs['frontend-components.json']) {\n const componentNames = new Set(\n (docs['frontend-components.json'].components || []).map((c) => c.name),\n );\n // routes should reference components that were actually inventoried\n for (const r of docs['frontend-routes.json']?.routes || []) {\n for (const c of r.usesComponents || []) {\n if (!componentNames.has(c)) {\n warnings.push(`frontend-routes.json:${r.path}: uses component \"${c}\" not in frontend-components.json`);\n }\n }\n }\n // a component flagged as needing a port must say WHY (the custom logic), or the\n // port-risk is unactionable\n for (const c of docs['frontend-components.json'].components || []) {\n if (c.rebuild === 'custom-logic' && !c.customLogic) {\n warnings.push(`frontend-components.json:${c.name}: rebuild=custom-logic but no customLogic description — port risk is unactionable`);\n }\n }\n // data-fetching queries named on routes should resolve to a real query/command\n if (docs['queries.json'] || docs['commands.json']) {\n const known = new Set([\n ...(docs['queries.json']?.queries || []).map((q) => q.name),\n ...(docs['commands.json']?.commands || []).map((c) => c.name),\n ]);\n for (const r of docs['frontend-routes.json']?.routes || []) {\n for (const d of r.dataFrom || []) {\n if (!known.has(d)) {\n warnings.push(`frontend-routes.json:${r.path}: reads \"${d}\" which is not a known query/command`);\n }\n }\n }\n }\n}\n\n// an inconsistent UI with no specific design findings = under-extraction\n// (guarded on frontend.json alone — independent of the component inventory)\nif (docs['frontend.json']) {\n const consistency = docs['frontend.json'].designSystemConsistency;\n const findingCount = (docs['frontend.json'].designFindings || []).length;\n if ((consistency === 'mixed' || consistency === 'ad-hoc') && findingCount === 0) {\n warnings.push(`frontend.json: designSystemConsistency=\"${consistency}\" but designFindings is empty — name the specific broken patterns (interaction/theming/cross-page/…)`);\n }\n}\n\nfor (const w of warnings) console.log(`WARN ${w}`);\nfor (const e of errors) console.log(`ERROR ${e}`);\nconsole.log(`\\n${errors.length} error(s), ${warnings.length} warning(s) across ${Object.keys(docs).length} files`);\nprocess.exit(errors.length ? 1 : 0);\n", "pikku-software-archaeology/SKILL.md": "---\nname: pikku-software-archaeology\ndescription: 'Use when reverse-engineering an existing repository into a Product Blueprint — recovering what product an undocumented or organically-grown codebase implements so it can be rebuilt cleanly (e.g. as a Pikku app). TRIGGER when: user says \"extract a blueprint\", \"reverse engineer this app\", \"what does this codebase actually do as a product\", \"prepare this repo for a rewrite/migration\", or points at a legacy repo (any language — JS, TS, Ruby, Python, PHP, Go) and asks for its domains, workflows, business rules, or a rebuild plan. DO NOT TRIGGER for: documenting code structure, generating API docs from an already-clean codebase, or code review.'\ninstallGroups: [fabric]\n---\n\n# Software Archaeology\n\n## Overview\n\nExtract **intent over implementation**. A repository is a fossil record of product decisions; your job is to recover the product — domains, entities, commands, queries, events, policies, workflows, invariants — not to describe the code. The output is a `.knowledge/` directory of schema-validated JSON plus a human-readable `blueprint.md`, consumable by a generator (Pikku) to rebuild the application cleanly.\n\n**You are the parser.** Do not build or rely on regex/AST scanners — read the code with your own tools (Grep, Read, subagents). This is what makes the skill language-agnostic: an Express app, a Rails app, and a Django app all yield the same blueprint shape.\n\n**Two layers, never merged silently:**\n- **Facts** — behavior directly observed in code, schema, or tests. Cite them.\n- **Inferred intent** — the product reasoning you reconstruct. Mark it with `confidence` and say what evidence it rests on.\n\nNever present a guess as a fact. `confidence: \"high\"` requires file:line evidence of the behavior itself.\n\n## Output Contract\n\nEverything goes in `<repo>/.knowledge/` (or a caller-specified directory):\n\n```\n.knowledge/\n├── product.json # purpose, actors, capabilities, terminology\n├── domains.json # business domains (NEVER folder names)\n├── entities.json # domain entities: attributes, relationships, states, transitions\n├── commands.json # state-changing actions (SendInvoice, not POST /invoices/:id/send)\n├── queries.json # read operations and views\n├── events.json # business facts, past tense (InvoicePaid) — no technical events\n├── policies.json # authorization, validation, state, business-constraint rules\n├── workflows.json # user + admin + system workflows, with test-derived scenarios\n├── api.json # every surface, each mapped to a command/query/event-ingress\n├── integrations.json # external services: purpose, direction, replaceability\n├── architecture.json # components, datastores, deployment constraints worth keeping\n├── invariants.json # what must ALWAYS be true, and what enforces it today\n├── gaps.json # TODOs, hacks, duplication, dead code, open product decisions\n├── migration.json # current files -> future concepts, drops, decisions needed\n├── blueprint.md # human synthesis of all of the above\n│\n│ # OPTIONAL — the consumer-surface layer. Emit these when the repo has a\n│ # frontend and/or non-HTTP consumer channels. A backend-only repo omits them\n│ # and the validator does not complain.\n├── interfaces.json # every way the product is consumed: web-ui, cli, mcp, openapi-rest, sdk, realtime, webhooks\n├── frontend.json # web-UI app shape: framework, router, styling, data layer, auth (e.g. TanStack Start + better-auth + Mantine)\n├── frontend-routes.json # the page/route tree — what a user navigates to, its data + components\n└── frontend-components.json# component inventory; `rebuild` splits trivial-Mantine from custom-logic-to-port\n```\n\nThe exact field shapes live in `references/blueprint.schema.json` (in this skill's directory — read it before writing any output file). After writing, ALWAYS run:\n\n```bash\nnode <skill-dir>/scripts/validate.mjs <repo>/.knowledge\n```\n\nand fix every ERROR (WARNs are prompts to double-check, not necessarily wrong). Do not declare the extraction done with validation errors outstanding.\n\n## The Pipeline\n\nWork in phases. For small repos (< ~50 source files) do them inline; for larger repos, fan out subagents per phase-3 lens and merge (see \"Scaling up\").\n\n### Phase 1 — Survey (facts only)\n\nBuild an inventory before interpreting anything:\n- Manifests (`package.json`, `Gemfile`, `pyproject.toml`, `go.mod`, `composer.json`): dependencies are integration hints; scripts are entry points.\n- Entry points: servers, route registrations, cron/scheduler setup, queue workers, CLI binaries.\n- Data layer: migrations, schema files, model classes, raw DDL (check comments too — schemas hide in comments in migration-less repos).\n- Every HTTP/GraphQL/RPC surface, webhook, scheduled job, queue consumer.\n- **All consumer channels, not just HTTP**: a frontend app (`apps/`, `frontend/`, `web/`, `client/`), a CLI (`bin/`, `wireCLI`, a `cli/` dir, an `openapi`-generated command tool), an MCP server (`wireMCP`, `@modelcontextprotocol`, a `mcp`/`tools` dir), an OpenAPI/Swagger spec (`openapi.json`, `swagger`), a published/generated SDK, realtime channels (websocket/SSE). Each is an `interfaces.json` entry.\n- Env vars and config files.\n- TODO / FIXME / HACK / XXX / deprecated markers — each is a gaps.json candidate.\n- **The test suite** — locate it now, excavate it in Phase 2.\n\nWhere intent hides, per ecosystem (read these first):\n\n| Ecosystem | Highest-yield locations |\n|---|---|\n| Rails | `config/routes.rb`, model validations + callbacks + `aasm`/state machines, `app/policies` (Pundit) / `ability.rb` (CanCan), Sidekiq/ActiveJob workers, `db/schema.rb`, specs (esp. request + model specs) |\n| Express/Node | route registration files, middleware chains (auth!), inline `if` guards in handlers, SQL/ORM models, `jobs/`+crontab refs, webhook handlers |\n| Django | `urls.py`, model `Meta`/constraints/`clean()`, DRF serializers + permissions classes, celery tasks, admin.py (reveals internal workflows) |\n| Laravel | `routes/`, FormRequests (validation), Policies/Gates, Jobs + scheduler in `Kernel.php`, migrations |\n| Go | mux/router setup, middleware, struct tags, `cmd/` binaries (each is a component) |\n| Frontend (React/Vue/etc.) | router config / file-based routes (pages a user reaches), the component tree, the design-system import (`@mantine/*`, `@mui/*`, Tailwind config) to judge consistency, the data layer (react-query/tRPC/fetch wrappers) to tie UI back to backend queries, charts/tables/editors (the `custom-logic` port risk), auth wiring |\n\n### Phase 2 — Test excavation (do not skip)\n\nTests are the closest thing to an executable product spec. For every test file:\n- `describe`/`context`/`it` names → **scenarios** (attach to the matching workflow in `workflows.json` under `scenarios[]`, with `fromTest` set).\n- User-flow/journey harnesses (`pikkuUserFlow` stories, cucumber `.feature` files, Playwright journeys) are the highest-grade scenario source — they already ARE given/when/outcome sequences; extract them verbatim.\n- Assertions → confirmations of policies and invariants (upgrade their `confidence` to `high`, add the test as evidence).\n- Fixtures/factories → entity attribute shapes and realistic example data.\n- Edge-case tests → business rules that exist **nowhere else in the code** (e.g. \"replayed webhook events are idempotent\" may only be stated in a test).\n- Untested-but-critical paths, or a test suite that can't run (missing helpers, broken setup) → `gaps.json`.\n\nA rule attested by both an implementation guard AND a test is your strongest possible evidence — cite both.\n\n### Phase 3 — Extraction lenses\n\nRun each lens over the surveyed material. Rules that counter the classic failure modes:\n\n**Domains** — infer from data ownership, workflows, and vocabulary; NEVER from folder names. `controllers/` is not a domain; \"Billing\" is. A domain owns entities and the commands that mutate them.\n\n**Entities** — domain concepts, not tables. Include: attributes (from schema + serializers + fixtures), relationships, **lifecycle states and transitions** (grep status/state columns, then find every write to them — each write site is a transition with a trigger), ownership (which actor's rows), constraints.\n\n**Commands** — every way state changes: routes, jobs, webhooks, CLI, admin consoles, DB triggers. Name them imperative `VerbNoun` in domain language: `POST /api/users/:id/status` → `ActivateUser`. For each: actor, preconditions (every `if (...) return 4xx` guard is a precondition or policy), effects, events produced. Convention: authentication/token issuance is a command (`LogInUser`, effect: \"issues a session/JWT\") even though it writes no rows — it changes the caller's security state.\n\n**Queries** — reads and views, `GetX`/`ListX`/`SearchX`. Record the tenancy scoping each applies (a missing `WHERE user_id=` that exists elsewhere is a gaps.json security entry).\n\n**Events** — meaningful business facts, past tense. Most legacy apps have **implicit** events: an email send, a status flip, and a counter bump inside one handler are the event's consumers — reconstruct `InvoicePaid` from them and set `explicit: false`. Exclude technical noise (ButtonClicked, FunctionCalled). Inclusion threshold for implicit events: at least one observed consumer beyond the row write itself (an email, a downstream job, a webhook out, a derived-state flip). Plain CRUD facts with no reaction (`ClientCreated` that nothing listens to) do not become events.\n\n**Policies** — authorization, validation, state rules, business constraints. Record **every** location enforcing each rule in `enforcedAt`; the same rule enforced in 3 places (or worse, 2 slightly different versions) is a gaps.json duplication entry.\n\n**Workflows** — ALL of them: user journeys, admin/support operations, and **system workflows** (cron jobs, queue consumers, webhook reactions, syncs, notification sweeps). A crontab line in a comment is a workflow. Attach Phase-2 scenarios.\n\n**API** — list every surface but map each to its concept (`mapsTo: {type: command, name: SendInvoice}`). The route is evidence; the command is the deliverable. Auth per surface (including \"none\" and capability-URLs like tokened public links). A webhook whose handler changes state maps to a **command** (`RecordInvoicePayment`); reserve `event-ingress` for pure relay surfaces, where `name` must be an events.json event.\n\n**Integrations** — from deps + config + calls: purpose, data exchanged, direction, importance, replacement difficulty, env vars.\n\n**Architecture** — components as they actually run (API process, worker, cron job, SPA), datastores, and deployment constraints that must survive the rewrite (hardcoded ports with upstream expectations, raw-body middleware ordering, webhook retry semantics).\n\n**Invariants** — what must always hold, and `enforcedBy`: db-constraint, code-guard, convention, or `nothing` (an unenforced invariant is a gap). Include `atRiskBecause` when enforcement is fragile (e.g. read-then-insert sequence numbering races).\n\n**Gaps** — incomplete features, TODOs, hacks (hardcoded admin emails), duplicated logic that drifted, dead code, unclear ownership, and **open product decisions** the code never resolved (a FIXME asking \"should deleting a client void invoices?\" is a product decision, record it in both gaps.json and migration.json `decisionsNeeded`).\n\n**Migration** — map current file clusters → future domain + concepts; list files to drop with reasons; list decisions a human must make before rebuild.\n\n**Interfaces** (`interfaces.json`, optional) — every way the product is CONSUMED, one entry per channel, not per route. A product is usually driven through several: a **web UI** (humans), a **CLI** (developers/operators), an **MCP server** (AI agents — in a Pikku app each MCP tool IS a `pikkuFunc`), an **OpenAPI/REST** surface (developers/external systems, often *generated* from the routes), a **generated SDK**, **realtime** (websocket/SSE), and **webhooks** (in/out). For each: `kind`, `audience`, `purpose`, roughly how many ops it exposes, whether it's `generated` vs hand-written, which domains it serves, and `status` (complete/partial/stub — an MCP server with two tools is `stub`). This layer answers \"who can drive this, and how\" — it is the map the second-opinion skill needs to explain that the app is usable by people, developers, and agents.\n\n**Frontend** (`frontend.json` + `frontend-routes.json` + `frontend-components.json`, optional) — the web UI, which needs its own treatment because frontends vary wildly (framework, router, styling, state, data, auth) and the rebuild target is opinionated: **everything in one component system (Mantine), one data layer, one auth**.\n- `frontend.json` records the stack as FACTS: framework (e.g. TanStack Start), rendering (SSR/streaming/SPA), router, styling/design system, `designSystemConsistency`, state management, data layer (e.g. pikku-react-query vs REST helpers), auth (e.g. better-auth), i18n. Name the real technologies — the second-opinion skill weighs their tradeoffs, so record them precisely (do NOT editorialize here; this file is facts).\n- `frontend-routes.json` is the page tree: each route's `purpose` in product terms, `auth`, the `dataFrom` (query/command names it reads — reuse the backend concept names so the UI ties back to the domain), the `usesComponents`, and the `userFlows` it belongs to.\n- `frontend.json.designFindings` captures **broken/inconsistent design patterns** as concrete, cited observations (not taste). Actively hunt for: *interaction inconsistency* (the same job done as a modal in one place and a drawer in another; inconsistent confirm dialogs); *theming not tokenized* (hardcoded hex colors, magic spacing/font sizes, inline styles instead of theme tokens/variables — grep for `#[0-9a-f]{3,6}`, `style={{`, raw `px` values); *cross-page inconsistency* (the same element — button, page header, card — styled differently across routes); *component duplication* (three near-identical cards/tables for one purpose); *design-system bypass* (raw HTML/CSS where a library component exists). Each finding gets an example, its impact (feels unpolished / a color change means hunting every file), and a fix (standardize on one pattern / move to tokens / extract one shared component). These are almost always cheap cleanups, and they are exactly what a non-technical owner perceives as \"the app looks off\" without being able to say why.\n- `frontend-components.json` is where the frontend's real migration cost lives, in the **`rebuild`** field: `mantine-standard` (maps 1:1 to a Mantine component — trivial), `mantine-composition` (built from Mantine primitives — straightforward), `custom-style` (diverges only visually — normalize to Mantine), or **`custom-logic`** (bespoke behavior — a custom chart, a virtualized/complex table, a canvas, drag-and-drop, a rich editor — that must be **ported**, not re-skinned). A `custom-logic` component MUST fill `customLogic` explaining the behavior, and should list the `dependencies` (charting/table/editor libs) that make it a real port. This split — \"trivially re-Mantine-able\" vs \"carries logic that must survive the port\" — is the single most useful thing the frontend extraction produces.\n- **Server-rendered / non-React frontends still get all three files — do NOT skip them.** The `rebuild` enum is named for the target stack, but the distinction it draws is target-agnostic: *trivial stock element* vs *composed from primitives* vs *visual divergence only* vs **bespoke behavior that must be ported**. A Rails app (Slim/ERB + ViewComponents + Hotwire/Stimulus), a Django app (templates + HTMX), or a Laravel app (Blade + Livewire) all have that same split, and it is just as load-bearing there. Use the enum values verbatim (the validator enforces them), classify by what the thing actually *does*, and record the vocabulary mismatch in `frontend.json.notes`. Concretely: treat a template partial + its behavior controller (Stimulus/Alpine/Livewire) as ONE component and classify the pair; a server-rendered app's `custom-logic` is the same list as a SPA's (maps, charts, video players, payment elements, drag-to-reorder, rich text, QR, live-updating regions), plus anything whose behavior rides on a streaming/partial-update contract (Turbo Streams, HTMX swaps) — get that mapping wrong on rebuild and pages show stale data. Omitting these files because \"it isn't a React app\" hides the frontend's entire migration cost, which is the one thing this file exists to expose.\n- The `designFindings` grep hints above are React-flavored; the server-rendered equivalents are inline `style=` attributes in templates, hardcoded hex in the stylesheet tree, per-locale forked templates instead of i18n, and design-system bypass = raw markup where a component/partial already exists. Same findings, different needles.\n\n### Phase 4 — Cross-check, validate, synthesize\n\n1. Cross-checks before writing: every command has an actor and at least one precondition or policy (or you explain why it is genuinely unguarded); every state transition appears in some command/workflow — if a state is only reachable by manual/DB intervention, record the transition with trigger `\"none (manual/DB-only)\"` AND add a gaps.json entry; every event has a producer; every api surface maps to a defined concept; every integration is used by some command/workflow.\n2. Write all 14 JSON files, sorting **unordered identity collections** by `name` (or `path`) — on first extraction too, not just re-runs. Arrays whose order carries meaning (`workflows[].steps`, and any other observed sequence) stay in their observed order: sorting them would rewrite the behavior you extracted. Run the validator: done means `0 error(s)` and exit 0, and every WARN explicitly reviewed and either fixed or justified in your summary.\n3. Write `blueprint.md`: product summary → domain map → per-domain narrative (entities/commands/events with the interesting rules) → workflows → integrations/architecture → invariants → gaps and open decisions → rebuild recommendation. Write it for the engineer who will rebuild the product and has never seen the legacy code.\n\n## Evidence & Confidence Discipline\n\n- Every concept object carries `evidence: [{file, lines, note}]` and `confidence`. (In `api.json` and `architecture.json`, `confidence` is optional — include it whenever a surface/component is inferred rather than directly observed, e.g. an SPA known only from comments.)\n- `high` — the behavior itself is in the cited code/schema/test.\n- `medium` — inferred from multiple converging signals (naming + partial code + a test name).\n- `low` — plausible reconstruction; MUST also be phrased tentatively in blueprint.md and usually deserves a `decisionsNeeded` entry.\n- Comments and docs describe *intended* behavior; code describes *actual* behavior. When they disagree, record the code's behavior as the fact and the disagreement as a gap.\n\n## Scaling up (large repos)\n\n- Fan out one subagent per lens (or per candidate domain) with: the survey notes, the schema file path, and instructions to return JSON fragments with evidence. Merge, dedupe by concept name, then run Phase 4 yourself.\n- Fix the domain cut YOURSELF after the survey and hand every lens agent the same canonical domain list — domains are the shared IDs everything cross-references.\n- Give each lens agent ownership of whole files (never two agents writing one file), and pair coupled files under one agent (commands+queries+api must share names).\n- **Budget a reconciliation pass — parallel agents WILL drift on names** (observed at real scale: 472 validator warnings from one 7-agent run). The pattern: rebuild `domains.json`'s entity/command/query/event/policy roll-up lists LAST, generated by grouping the authoritative files by their `domain` field — never hand-written before those files exist; reconcile `eventsProduced` against curated `events.json` by rename (spelling variant) / drop (CRUD noise, no consumer) / add (only with verified consumer evidence); fill empty command policies by joining `api.json`'s per-surface `auth` against `policies.json` names. The validator's warning list is the reconciliation worklist.\n- **Incremental re-runs:** keep concept names stable (they are the IDs). Re-run the affected lens only, diff against the existing `.knowledge/` files, and preserve unrelated entries verbatim. Sort every array by `name` (or `path`) so diffs are meaningful.\n\n## Red Flags — you are about to produce a worthless blueprint\n\n| Thought | Reality |\n|---|---|\n| \"I'll write it up as markdown docs\" | Only `blueprint.md` is prose. The 14 JSON files ARE the deliverable; a generator consumes them. |\n| \"The routes are the API contract\" | Routes are evidence. Lift each to a command/query or you've documented plumbing, not product. |\n| \"This is obvious, no citation needed\" | Uncited claims are indistinguishable from hallucinations. Evidence on everything. |\n| \"The folder structure tells me the domains\" | Folders are how it grew, not what it is. Derive domains from ownership + vocabulary. |\n| \"Tests are just tests, skip them\" | Tests are the spec. Some rules exist ONLY in tests. Phase 2 is mandatory. |\n| \"No events are emitted, so events: []\" | Reconstruct implicit events from side-effect clusters; mark `explicit: false`. |\n| \"Cron jobs aren't workflows\" | System workflows are workflows. Include schedules, queue consumers, webhook reactions. |\n| \"The frontend is just the web routes\" | The web UI is ONE interface. Inventory the CLI, MCP server, OpenAPI/SDK, realtime, webhooks in `interfaces.json` too — the product is driven by people, developers, AND agents. |\n| \"A component list is enough\" | Without the `rebuild` split, you've hidden the frontend's real cost. Flag every `custom-logic` component (chart/table/canvas/editor) and say what the logic is — that's the port work. |\n| \"I'll skip the validator, the JSON looks right\" | Run it. Missing domains refs, dangling event names, and undescribed custom-logic components are exactly what it catches. |\n\n## Quick Reference\n\n```bash\n# 1. survey + excavate + extract (you, with Read/Grep/subagents)\n# 2. write <repo>/.knowledge/*.json + blueprint.md per references/blueprint.schema.json\n# 3. validate:\nnode <skill-dir>/scripts/validate.mjs <repo>/.knowledge\n```\n\n- Schema/contract: `references/blueprint.schema.json`\n- How Pikku consumes the blueprint: `references/pikku-mapping.md`\n", "pikku-tag-middleware/SKILL.md": "---\nname: pikku-tag-middleware\ndescription: 'Deprecated — use pikku-middleware instead. Tag middleware (addTagMiddleware) is now documented as a section within the pikku-middleware skill, alongside global HTTP middleware, execution order, and the service-to-service bearer auth pattern.'\n---\n\n# Deprecated: use `pikku-middleware`\n\nTag middleware is covered in the **`pikku-middleware`** skill, which also covers:\n- `addHTTPMiddleware` (global / prefix-based)\n- `addTagMiddleware` (tag-scoped)\n- Middleware execution order and priority\n- Service-to-service bearer auth pattern\n- Session-setting middleware pattern\n", "pikku-template-clone/SKILL.md": "---\nname: pikku-template-clone\ndescription: 'Standard cleanup to run right after a Pikku template is cloned or scaffolded into a new project. TRIGGER when: a Pikku template was just cloned/scaffolded (via `npm create pikku`, `git clone <template>`, or the user says \"I cloned the kanban template / starter / template\"), or the working tree still looks like an untouched template (template README, placeholder `@project/*` name in package.json). DO NOT TRIGGER when: working in an established project mid-feature, or editing the template repo itself.'\nallowed-tools: Bash(git status *), Bash(git add *), Bash(git commit *), Bash(git rm *), Bash(git mv *), Bash(git log *)\ninstallGroups: [core]\n---\n\n# Pikku Template Post-Clone Cleanup\n\n## Agent Operating Procedure\n\nRun this **once**, right after a template is cloned or scaffolded into a new\nproject. The goal is to turn template scaffolding into a real project. Make the\nsmallest changes and land them as one focused `chore: post-clone cleanup`\ncommit, separate from any feature work.\n\n1. **Replace the template README.** The shipped `README.md` describes the\n _template_, not the user's project — leaving it in place is misleading.\n Either delete it (`git rm README.md`) or rewrite it with the new project's\n name and purpose. Never ship a clone with the generic template README.\n2. **Keep the lockfile committed.** Do NOT re-add `yarn.lock` to `.gitignore`.\n A real project commits its lockfile for reproducible installs. The correct\n pattern is `yarn.lock` followed by `!/yarn.lock`, which commits the root\n lockfile while keeping generated per-unit lockfiles under `.deploy/` (and\n `e2e/`) ignored.\n\n `create-pikku` keeps only the chosen package manager's lockfile and deletes\n the other, and for yarn it may have written an **empty** `yarn.lock` as a\n marker. Commit the lockfile *after* the first install has filled it in —\n committing the empty placeholder pins nothing.\n3. **Rename template identifiers.** Update `name` in the root `package.json`\n (and any `@project/*` or other placeholder names) to the real project.\n4. **Drop template-only artifacts.** Remove any `TEMPLATE.md`, demo docs, or\n placeholder content that only made sense for the template.\n\nDo not touch generated files (`.pikku/`, `*.gen.*`) or run a full reinstall as\npart of cleanup — this step is project hygiene, not a build.\n\n## Why this exists\n\nTemplates are structure-only starting points. Without this pass, clones carry a\nmisleading README, a placeholder package name, and (historically) a gitignored\nlockfile — all of which leak template assumptions into a real project. Running\nit immediately after clone keeps every Pikku project, OSS or Fabric, starting\nfrom a clean, honest baseline.\n", "pikku-trigger/SKILL.md": "---\nname: pikku-trigger\ndescription: >-\n Use when adding event-driven functions that respond to system events like Redis pub/sub,\n PostgreSQL LISTEN/NOTIFY, or custom event sources. Covers wireTrigger, wireTriggerSource, and\n pikkuTriggerFunc. TRIGGER when: code uses wireTrigger/wireTriggerSource/pikkuTriggerFunc, user\n asks about event-driven functions, Redis pub/sub, PostgreSQL LISTEN/NOTIFY, or reacting to\n external events. DO NOT TRIGGER when: user asks about scheduled tasks (use pikku-cron) or\n background job queues (use pikku-queue).\ninstallGroups: [core]\n---\n\n# Pikku Trigger Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to fire when external events occur. Triggers connect event sources (Redis pub/sub, PostgreSQL LISTEN/NOTIFY, polling, webhooks) to Pikku functions.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## API Reference\n\nAll three come from `#pikku`. A trigger is deliberately split in two: the\n**source** owns the connection to the outside world and the **trigger** names the\nfunction to run, so one source can be swapped (Redis → PG) without touching the\nhandler, and a handler can exist before any source is wired.\n\n### `wireTrigger(config)`\n\nDefine the target function that handles trigger events:\n\n```typescript\nimport { wireTrigger } from '#pikku'\n\nwireTrigger({\n name: string, // Trigger name (matches source)\n func: PikkuFunc, // Function to call when event fires\n description?: string,\n tags?: string[],\n})\n```\n\n### `wireTriggerSource(config)`\n\nDefine the event source that fires triggers:\n\n```typescript\nimport { wireTriggerSource } from '#pikku'\n\nwireTriggerSource({\n name: string, // Must match a wireTrigger name\n func: PikkuTriggerFunc, // Source function (sets up the listener)\n input: object, // Configuration handed to the source\n})\n```\n\n`input` is required whenever the source function declares an input type, and the\nname must be unique — wiring the same source name twice throws\n`Trigger source already exists`.\n\n### `pikkuTriggerFunc<TInput, TEvent>`\n\nA trigger source function runs **once at startup**, not once per event. It sets\nup a listener, calls `trigger.invoke(...)` for each event it sees, and returns a\nteardown function:\n\n```typescript\nimport { pikkuTriggerFunc } from '#pikku'\n\nconst source = pikkuTriggerFunc<\n InputType, // Configuration input\n EventType // Shape of events it emits\n>(async (services, input, { trigger }) => {\n // Set up listener...\n trigger.invoke(eventData) // Fire the trigger\n\n // Return cleanup function\n return async () => {\n /* teardown */\n }\n})\n```\n\nIt receives **singleton services only** — there is no session, no request and no\nper-wire services, because a listener outlives every event it will ever emit.\nThe config-object form (`pikkuTriggerFunc({ func, title, description, tags,\ninput, output })`) is also accepted when you want schemas or metadata on the\nsource.\n\n## Starting triggers\n\nNothing fires until a `TriggerService` is started. For a single process,\n`InMemoryTriggerService` walks every wired source that has at least one matching\ntarget and sets it up:\n\n```typescript\nimport { InMemoryTriggerService } from '@pikku/core/services'\n\nconst triggerService = new InMemoryTriggerService()\nawait triggerService.start()\n// on shutdown\nawait triggerService.stop() // runs every source's teardown\n```\n\nA source with no matching `wireTrigger` is logged and skipped rather than\nerroring — the two halves are wired independently, so a half-wired trigger is a\nnormal intermediate state.\n\n**If a wiring is silently skipped**, look for\n`Skipping trigger … metadata not found` in the logs. Both wirings read metadata\ngenerated by the inspector, and it warns rather than throwing; the usual fix is\nthe one the warning suggests — move the wiring into its own file so codegen\npicks it up.\n\n## Usage Patterns\n\n### Redis Pub/Sub Source\n\n```typescript\nconst redisSubscribe = pikkuTriggerFunc<\n { channels: string[] },\n { channel: string; message: any }\n>(async ({ redis }, { channels }, { trigger }) => {\n const subscriber = redis.duplicate()\n\n subscriber.on('message', (channel, message) => {\n trigger.invoke({ channel, message: JSON.parse(message) })\n })\n\n await subscriber.subscribe(...channels)\n\n return async () => {\n await subscriber.unsubscribe()\n await subscriber.quit()\n }\n})\n\n// Target function\nconst onOrderEvent = pikkuSessionlessFunc({\n title: 'On Order Event',\n func: async ({ db, logger }, { channel, message }) => {\n logger.info(`Order event on ${channel}`, message)\n await db.processOrderEvent(message)\n },\n})\n\n// Wire them together\nwireTrigger({\n name: 'order-events',\n func: onOrderEvent,\n})\n\nwireTriggerSource({\n name: 'order-events',\n func: redisSubscribe,\n input: { channels: ['orders:created', 'orders:updated'] },\n})\n```\n\n### Triggers vs Queues\n\n| Feature | Trigger | Queue |\n| ----------- | ---------------------------------- | ------------------------------ |\n| Execution | Synchronous, in-process | Async, distributed |\n| Reliability | At-most-once | At-least-once (with retries) |\n| Use case | React to events immediately | Reliable background processing |\n| Source | External systems (Redis, PG, etc.) | Enqueued programmatically |\n\nUse triggers for real-time reactions. Use queues for reliable, retryable background work.\n\n## Complete Example\n\n```typescript\n// functions/triggers.functions.ts\nconst pgListen = pikkuTriggerFunc<{ channel: string }, { payload: any }>(\n async ({ db }, { channel }, { trigger }) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(channel)) {\n throw new Error(`Invalid channel name: ${channel}`)\n }\n const client = await db.pool.connect()\n\n client.on('notification', (msg) => {\n trigger.invoke({ payload: JSON.parse(msg.payload) })\n })\n\n await client.query(`LISTEN ${channel}`)\n\n return async () => {\n await client.query(`UNLISTEN ${channel}`)\n client.release()\n }\n }\n)\n\nconst onUserCreated = pikkuSessionlessFunc({\n title: 'On User Created',\n func: async ({ emailService, logger }, { payload }) => {\n logger.info('New user created', { userId: payload.id })\n await emailService.sendWelcome(payload.email)\n },\n})\n\n// wirings/triggers.wiring.ts\nwireTrigger({ name: 'user-created', func: onUserCreated })\nwireTriggerSource({\n name: 'user-created',\n func: pgListen,\n input: { channel: 'user_created' },\n})\n```\n", "pikku-versioning/SKILL.md": "---\nname: pikku-versioning\ndescription: >-\n Use when versioning Pikku function contracts, detecting breaking changes, or managing API\n backward compatibility. Covers the version property, versions.pikku.json manifest, contract\n hashing, and CI integration. TRIGGER when: code uses version: on a pikkuFunc, user asks about\n API versioning, breaking changes, contract hashes, backward compatibility, or \"pikku versions\"\n CLI commands. DO NOT TRIGGER when: user asks about secrets/variables/OAuth2 (use pikku-config)\n or general function definitions (use pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku Function Versioning\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nTrack and protect function contracts across releases. Pikku hashes each function's input/output schema into a manifest so you can detect breaking changes before they ship.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their versions\n```\n\nSee `pikku-concepts` for the core mental model.\n\n## Function Versioning\n\nA function with `version: N` is registered under the id `name@vN`. The bare\nname still resolves to it, so callers that don't care about versions keep\nworking while a pinned `getBook@v1` stays addressable for the ones that do.\n\n**The pattern:** when you need to introduce a breaking change, copy the current\nfunction into a pinned `v1` and bump the live one to `version: 2`.\n\n1. Create `my-function-v1.function.ts` exporting `getBookV1` with `version: 1` —\n the trailing `V1` matching the version is stripped automatically, so the id\n becomes `getBook@v1`\n2. Add `version: 2` to the existing `getBook`\n\n```typescript\n// my-function-v1.function.ts — old contract, kept for running workflows/agents\nexport const getBookV1 = pikkuFunc({\n version: 1, // id becomes getBook@v1 — the V1 suffix is stripped\n input: z.object({ bookId: z.string() }),\n output: z.object({ title: z.string() }),\n func: async ({ db }, { bookId }) => {\n return db.getBook(bookId)\n },\n})\n\n// my-function.function.ts — latest contract, id becomes getBook@v2\nexport const getBook = pikkuFunc({\n version: 2,\n input: z.object({\n bookId: z.string(),\n format: z.enum(['full', 'summary']),\n }),\n output: z.object({\n title: z.string(),\n author: z.string(),\n isbn: z.string(),\n }),\n func: async ({ db }, { bookId, format }) => {\n return db.getBook(bookId, format)\n },\n})\n```\n\n**Bump the live function explicitly.** Nothing promotes an unversioned function\nto the next version for you — without `version: 2` it is treated as version 1 of\nthe `getBook` contract, colliding with the pinned `getBook@v1` and making\n`versions check` report the published contract as modified.\n\n**`override` is the escape hatch, not the requirement.** The contract key comes\nfrom the exported name with a matching `V<n>` suffix removed, so\n`getBookV1` + `version: 1` already lands on `getBook`. Use\n`override: 'getBook'` only when the export can't follow that convention — for\ninstance `legacyGetBook` with `version: 1`, which would otherwise key under\n`legacyGetBook`.\n\n## Version Manifest (`versions.pikku.json`)\n\nPikku tracks contract hashes to detect breaking changes:\n\n```json\n{\n \"manifestVersion\": 1,\n \"contracts\": {\n \"createTodo\": {\n \"latest\": 1,\n \"versions\": {\n \"1\": { \"inputHash\": \"a1b2c3d4\", \"outputHash\": \"e5f6a7b8\" }\n }\n },\n \"getTodos\": {\n \"latest\": 2,\n \"versions\": {\n \"1\": { \"inputHash\": \"i9j0k1l2\", \"outputHash\": \"m3n4o5p6\" },\n \"2\": { \"inputHash\": \"q7r8s9t0\", \"outputHash\": \"u1v2w3x4\" }\n }\n }\n }\n}\n```\n\nEach hash is derived from the function's input and output schemas plus the\ncontract key. If a schema changes without a version bump, `pikku versions check`\nwill fail.\n\nThe manifest lives at `versions.pikku.json` in the project's `rootDir`, and its\npresence is what switches versioning on — with no manifest, nothing is checked.\n\n## CLI Commands\n\n```bash\nnpx pikku versions init # Create an empty versioning manifest (run once)\nnpx pikku versions check # Detect contract changes (use in CI)\nnpx pikku versions update # Record current contract hashes\n```\n\n`init` writes `{ \"manifestVersion\": 1, \"contracts\": {} }` and nothing more — it\ndoes **not** capture the hashes of the functions you already have. Run\n`versions update` straight after it to record the current state, otherwise\n`check` has nothing to compare against and silently passes.\n\n`update` refuses to save when a published version's hash changed, so it can\nnever overwrite an immutable record; it reports that as a diagnostic and leaves\nthe manifest alone. Fix the contract or bump the version, then run it again.\n\n**Workflow:**\n\n1. `pikku versions init` then `pikku versions update` — once, to create and\n populate `versions.pikku.json`\n2. Develop normally — add/modify functions\n3. `pikku versions check` — CI catches unversioned breaking changes\n4. If intentional: pin the old contract as `…V1` with `version: 1`, bump the\n live function to `version: 2`, then `pikku versions update`\n\n## CI Integration\n\n```yaml\n# .github/workflows/ci.yml\nname: CI\non: [push, pull_request]\n\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: npm ci\n - run: npx pikku versions check\n```\n\n## Complete Example\n\n```typescript\n// create-todo-v1.function.ts — v1 locked contract, id: createTodo@v1\nexport const createTodoV1 = pikkuSessionlessFunc({\n version: 1,\n input: z.object({ title: z.string() }),\n output: z.object({ id: z.string(), title: z.string() }),\n func: async ({ todoStore }, { title }) => todoStore.add(title),\n})\n\n// create-todo.function.ts — v2 (latest), called by default\nexport const createTodo = pikkuSessionlessFunc({\n version: 2,\n input: z.object({\n title: z.string(),\n priority: z.enum(['low', 'medium', 'high']),\n }),\n output: z.object({\n id: z.string(),\n title: z.string(),\n priority: z.string(),\n }),\n func: async ({ todoStore }, { title, priority }) =>\n todoStore.add(title, priority),\n})\n```\n\nResult in manifest:\n\n```json\n\"createTodo\": {\n \"latest\": 2,\n \"versions\": {\n \"1\": { \"inputHash\": \"...\", \"outputHash\": \"...\" },\n \"2\": { \"inputHash\": \"...\", \"outputHash\": \"...\" }\n }\n}\n```\n", "pikku-websocket/SKILL.md": "---\nname: pikku-websocket\ndescription: >-\n Use when adding real-time features, WebSocket channels, live updates, chat, or pub/sub to a\n Pikku app. Covers wireChannel, action routing, auth, EventHub pub/sub, channel middleware, and\n generated WebSocket client. TRIGGER when: code uses wireChannel, user asks about WebSocket,\n real-time, live updates, chat, pub/sub, or the generated WebSocket client. DO NOT TRIGGER when:\n user asks about HTTP/REST (use pikku-http), SSE (use pikku-http with sse: true), or WebSocket\n deployment specifics (use pikku-deploy-uws), or typed pub/sub events (use pikku-realtime).\ninstallGroups: [core]\n---\n\n# Pikku WebSocket Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWire Pikku functions to WebSocket channels with structured message routing, auth per-action, pub/sub via EventHub, and auto-generated type-safe clients.\n\n## Before You Start\n\n```bash\npikku info functions --verbose # See existing functions and their types\npikku info tags --verbose # Understand project organization\n```\n\nFollow existing patterns. See `pikku-concepts` for the core mental model.\n\n## API Reference\n\n### `wireChannel(config)`\n\n```typescript\nimport { wireChannel } from '@pikku/core/channel'\n\nwireChannel({\n name: string, // Channel name (e.g. 'todos')\n route: string, // REQUIRED — the URL path (e.g. '/todos')\n auth?: boolean, // Channel-level auth default\n onConnect?: PikkuFunc, // Called when client connects\n onDisconnect?: PikkuFunc, // Called when client disconnects\n onMessage?: PikkuFunc, // Catch-all for unrouted messages\n onMessageWiring?: { // TWO levels — see below\n [messageField: string]: {\n [fieldValue: string]: {\n func: PikkuFunc,\n auth?: boolean, // Override channel-level auth\n middleware?: PikkuMiddleware[],\n }\n }\n },\n middleware?: PikkuMiddleware[],\n channelMiddleware?: PikkuChannelMiddleware[],\n binary?: boolean | null,\n onBinaryMessage?: (services, data, channel) => ...,\n tags?: string[], // Targets tag middleware\n})\n```\n\nNote there is **no `permissions` key on a message wiring** — wire-level\npermissions were removed in #972. Authorization lives on the function's own\n`permissions` field (see `pikku-permissions`).\n\n### `pikkuChannelMiddleware(fn)`\n\n```typescript\nimport { pikkuChannelMiddleware } from '@pikku/core'\n\nconst middleware = pikkuChannelMiddleware(async (services, event, next) => {\n // Transform or filter events before/after\n await next(event) // Pass modified event, or next(null) to drop\n})\n```\n\n### `addChannelMiddleware(domain, middlewares)`\n\n```typescript\naddChannelMiddleware('todos', [addTimestamp, filterSensitive])\n```\n\n## Usage Patterns\n\n### Basic Channel\n\n```typescript\nwireChannel({\n name: 'todos',\n route: '/todos',\n onMessageWiring: {\n action: { // ← the field to route on\n create: { func: createTodo }, // ← its possible values\n list: { func: listTodos, auth: false },\n },\n },\n})\n```\n\n### Action Routing with Auth\n\n`onMessageWiring` nests two levels because the routing key is configurable. The\n**outer** key names the field in the incoming message to dispatch on; the\n**inner** keys are the values that field can take. With the conventional outer\nkey `action`, a client sending `{ action: 'create', data: {...} }` reaches\n`createTodo` — but a CLI channel might route on `command` instead, which is why\nthe field is not hardcoded.\n\n```typescript\nconst authenticate = pikkuSessionlessFunc({\n title: 'Authenticate',\n // setSession lives on the WIRE (third param), not on services\n func: async (services, { token }, { setSession }) => {\n const session = await verifyJWT(token)\n await setSession(session)\n return { success: true }\n },\n})\n\nwireChannel({\n name: 'todos',\n route: '/todos',\n auth: true,\n onMessageWiring: {\n action: {\n authenticate: { func: authenticate, auth: false }, // No session required\n subscribe: { func: subscribeTodos }, // Session required\n create: { func: createTodo },\n },\n },\n})\n```\n\n### Pub/Sub with EventHub\n\nUse EventHub for real-time broadcasting across connections:\n\n```typescript\nwireChannel({\n name: 'todos',\n route: '/todos',\n // eventHub is a service (1st param); channel lives on the wire (3rd)\n onConnect: async ({ eventHub }, _data, { channel }) => {\n eventHub.subscribe('todos:updated', (data) => {\n channel.send(data)\n })\n },\n onMessageWiring: {\n action: {\n create: {\n func: pikkuFunc({\n title: 'Create Todo',\n func: async ({ db, eventHub }, { text }) => {\n const todo = await db.createTodo({ text })\n eventHub.publish('todos:updated', {\n event: 'created',\n todo,\n })\n return { todo }\n },\n }),\n },\n },\n },\n})\n```\n\n### Channel Middleware\n\n```typescript\nconst addTimestamp = pikkuChannelMiddleware(\n async ({ logger }, event, next) => {\n logger.info({ phase: 'before-send', event })\n await next({ ...event, sentAt: Date.now() })\n }\n)\n\nconst filterSensitive = pikkuChannelMiddleware(\n async (_services, event, next) => {\n if (event.internal) return await next(null) // Drop event\n await next(event)\n }\n)\n\n// Apply globally to a domain\naddChannelMiddleware('todos', [addTimestamp, filterSensitive])\n\n// Or inline on wiring\nwireChannel({\n name: 'todos',\n route: '/todos',\n channelMiddleware: [addTimestamp],\n onMessageWiring: { ... },\n})\n```\n\n### Generated WebSocket Client\n\nAfter `npx pikku all`:\n\n```typescript\nimport { PikkuWebSocket } from '#pikku/pikku-websocket.gen.js'\n\nconst pikku = new PikkuWebSocket(ws)\nconst todosRoute = pikku.getRoute('todos')\n\n// Send action (type-safe)\nconst result = await todosRoute.send('create', { text: 'Buy milk' })\n\n// Subscribe to events\ntodosRoute.subscribe('todos:updated', (data) => {\n console.log(data.event, data.todo)\n})\n```\n\n## Complete Example\n\n```typescript\n// functions/chat.functions.ts\nexport const authenticate = pikkuSessionlessFunc({\n title: 'Authenticate',\n func: async ({ jwt }, { token }, { setSession }) => {\n const payload = await jwt.verify(token)\n setSession({ userId: payload.userId })\n return { success: true }\n },\n})\n\nexport const sendMessage = pikkuFunc({\n title: 'Send Message',\n func: async ({ db, eventHub }, { text }, { session }) => {\n const message = await db.createMessage({\n text,\n userId: session.userId,\n })\n eventHub.publish('chat:message', { message })\n return { message }\n },\n})\n\nexport const listMessages = pikkuSessionlessFunc({\n title: 'List Messages',\n func: async ({ db }, { limit }) => {\n return { messages: await db.listMessages(limit) }\n },\n})\n\n// wirings/chat.channel.ts\nwireChannel({\n name: 'chat',\n route: '/chat',\n auth: true,\n onConnect: async ({ eventHub }, _data, { channel }) => {\n eventHub.subscribe('chat:message', (data) => {\n channel.send(data)\n })\n },\n onMessageWiring: {\n action: {\n authenticate: { func: authenticate, auth: false },\n send: { func: sendMessage },\n history: { func: listMessages, auth: false },\n },\n },\n})\n```\n", "pikku-workflow/references/workflow-reference.md": "# Pikku Workflow Reference\n\n## Step execution: inline vs queue dispatch\n\nWhether a step runs **inline** (same process/session, no queue round-trip) or is **dispatched to the queue** is decided **purely by the step's function** — there is no workflow-level or per-call dispatch flag. `workflow.do(...)` options are only `description`/`retries`/`retryDelay`/`onError`.\n\n- **Steps default to inline.** Most steps don't need their own worker; running them inline avoids a queue round-trip per step, so a normally-started workflow executes its whole chain in one orchestrator pass.\n- **`workflowQueued: true` opts a function out.** Set it on the **function config** (`pikkuFunc` / `pikkuSessionlessFunc`, same level as `auth`/`expose`) to dispatch that step via the queue — for expensive/long-running steps that deserve their own worker, retry isolation, and concurrency limits. `workflowRetries` and `workflowTimeout` sit alongside it.\n- **Run-level `inline` is separate** and only controls whether the *whole run* executes in-process without queue infrastructure (set automatically when there is no `queueService`, or via `startWorkflow(..., { inline: true })`). It governs sleep handling, not per-step dispatch.\n\nThe rule (`dispatchStep`):\n\n| Function `workflowQueued` | `queueService` present? | Result |\n|---|---|---|\n| default / `false` | any | **inline** |\n| `true` | yes | **queued** (own worker) |\n| `true` | no | **throws** |\n\n```typescript\n// Push this one expensive step onto the queue; every other step stays inline:\nexport const renderLargeReport = pikkuSessionlessFunc({\n workflowQueued: true, // dispatch via queue instead of running inline\n workflowRetries: 3,\n workflowTimeout: '5m',\n input: ReportInput,\n output: ReportOutput,\n func: async (services, data) => { /* ... */ },\n})\n```\n\n`workflowQueued: true` **requires** a `queueService`. Without one the step throws\nrather than quietly running inline — a step marked for its own worker usually\ncarries timeout and concurrency expectations that inline execution would silently\nviolate, so failing loudly is safer than proceeding.\n\n## HTTP workflow wiring (manual)\n\nUsually auto-scaffolded via `scaffold.workflow`. To wire by hand:\n\n```typescript\n// Start a workflow\nwireHTTP({ method: 'post', route: '/onboard', func: workflowStart('onboardUser') })\n\n// Execute workflow steps (called by the orchestrator)\nwireHTTP({ method: 'post', route: '/onboard/run', func: workflow('onboardUser') })\n\n// Check workflow status\nwireHTTP({ method: 'get', route: '/onboard/status/:runId', func: workflowStatus('onboardUser') })\n```\n\n## Suspend / resume example\n\n```typescript\nimport { z } from 'zod'\nimport { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nexport const approval = pikkuWorkflowFunc({\n description: 'Submit a request and wait for approval',\n input: z.object({ requestId: z.string() }),\n output: z.object({ approved: z.boolean() }),\n func: async (services, data, { workflow }) => {\n await workflow.do('Submit request', 'submitRequest', data)\n await workflow.suspend('Awaiting approval') // pauses here until externally resumed\n const result = await workflow.do('Check result', 'getApprovalResult', data)\n return { approved: result.approved }\n },\n})\n```\n", "pikku-workflow/SKILL.md": "---\nname: pikku-workflow\ndescription: >-\n Use when building multi-step workflows, state machines, or orchestration pipelines with Pikku.\n Covers pikkuWorkflowFunc, workflow steps (do, sleep, suspend), graph workflows, and HTTP wiring.\n TRIGGER when: code uses pikkuWorkflowFunc/pikkuWorkflowGraph, user asks about workflows,\n multi-step processes, durable execution, suspend/resume, or DAG orchestration. DO NOT TRIGGER\n when: user asks about simple background jobs (use pikku-queue) or scheduled tasks (use\n pikku-cron).\ninstallGroups: [core]\n---\n\n# Pikku Workflow Wiring\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Capture baseline. Run `pikku-verify` (or `pikku all`) BEFORE writing code; note existing errors — only NEW errors are yours to fix.\n2. Discover before editing. Prefer `pikku-meta` / `pikku info functions --verbose` and `pikku info tags --verbose` to see functions usable as steps and project organization; inspect only the focused output you need.\n3. Identify the source files that own the behavior. Do not start from generated output, `.pikku`, `node_modules`, vendored packages, or build artifacts.\n4. Make the smallest source change. Keep generated files generated — never hand-edit SDKs, schema output, or typegen to paper over errors; fix the source cause.\n5. Validate with the narrowest relevant command, then re-run `pikku-verify`. If only files you did not touch still error, those are pre-existing — leave them unless asked.\n6. Call `pikku-workflow-view` only when `pikku-verify` fully passes (codegen AND type check both green) — never after a partial pass.\n\nSee `pikku-concepts` for the core mental model.\n\nBuild durable, multi-step workflows with automatic retry, sleep, suspend/resume, and parallel execution. Steps are cached for replay safety.\n\n## Choosing the right factory\n\n| Factory | When to use | Step-graph view? |\n| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `pikkuWorkflowFunc` | **Default for all new workflows.** Sequential + conditional logic; DSL mode (serialisable, replay-safe). ALL `const`/`let` declarations must be at the top level of the function body (not inside blocks). | ✅ Yes |\n| `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |\n| `pikkuWorkflowComplexFunc` | Escape hatch only — arbitrary TypeScript, no top-level restriction (e.g. dynamic inline functions the DSL extractor cannot handle). | ❌ No (loses step-graph view) |\n\n**Default to `pikkuWorkflowFunc`.** Use `pikkuWorkflowGraph` ONLY with explicit user approval AND only for a genuine cyclic dependency or Node.js-only import DSL cannot express. Use `pikkuWorkflowComplexFunc` ONLY with explicit user approval — a last-resort escape hatch. Never switch to either just to dodge a PKU641 error; restructure the code instead.\n\n### PKU641 — DSL static analysis error\n\n`pikkuWorkflowFunc` statically analyzes the body: **every `const`/`let` must be top-level, not inside any block (`if`, `for`, `while`, …).** Assignments inside blocks are fine — only declarations trigger it.\n\n```typescript\n// ❌ PKU641 — declaration inside block\nif (priority === 'high') {\n const bugCard = await workflow.do(...)\n}\n\n// ✅ hoist the declaration, assign inside the block\nlet bugCard: Awaited<ReturnType<typeof workflow.do>>\nif (priority === 'high') {\n bugCard = await workflow.do(...)\n}\n```\n\n## Import path\n\n```typescript\n// CORRECT — workflow factories come from the generated types file\nimport {\n pikkuWorkflowFunc,\n pikkuWorkflowGraph,\n pikkuWorkflowComplexFunc,\n} from '#pikku/workflow/pikku-workflow-types.gen.js'\n\n// WRONG — '#pikku' does not re-export them (TS2305)\nimport { pikkuWorkflowFunc } from '#pikku'\n```\n\n## Defining a workflow\n\nDeclare input/output as Zod schemas (like any function) — never TypeScript generic params (no `pikkuWorkflowFunc<In, Out>(...)`; that skips runtime validation). `data` is typed from the input schema.\n\n```typescript\nimport { z } from 'zod'\nimport { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nconst ProcessOrderInput = z.object({ orderId: z.string(), amount: z.number() })\nconst ProcessOrderOutput = z.object({\n status: z.string(),\n discount: z.number().optional(),\n})\n\nexport const processOrder = pikkuWorkflowFunc({\n description: 'Process an order through payment and fulfillment',\n tags: ['orders'],\n input: ProcessOrderInput,\n output: ProcessOrderOutput,\n func: async (services, data, { workflow }) => {\n // Declare ALL variables at top level — even those only assigned in branches (PKU641)\n let discount: number | undefined\n let status: string\n\n if (data.amount > 1000) {\n const d = await workflow.do('Apply bulk discount', 'calcDiscount', {\n amount: data.amount,\n })\n discount = d.discountPercent\n }\n\n const payment = await workflow.do('Charge', 'chargePayment', {\n orderId: data.orderId,\n amount: discount ? data.amount * (1 - discount / 100) : data.amount,\n })\n\n if (payment.success) {\n await workflow.do('Fulfill', 'fulfillOrder', { orderId: data.orderId })\n status = 'fulfilled'\n } else {\n status = 'payment-failed'\n }\n\n return { status, discount }\n },\n})\n```\n\n### Workflow step types\n\n```typescript\n// RPC step — run a registered Pikku function as a step (opts: retries, retryDelay, description)\nconst result = await workflow.do('Step name', 'rpcFunctionName', { ...data }, { retries: 3, retryDelay: '1s' })\n\n// Inline closure step — immediate execution, cached for replay\nconst msg = await workflow.do('Generate', async () => `Welcome, ${data.email}!`)\n\n// Sleep — durable pause (duration: '30s', '5min', '1h', '1d')\nawait workflow.sleep('Wait 5 minutes', '5min')\n\n// Suspend — pause until externally resumed (e.g. awaiting approval), then continue\nawait workflow.suspend('Awaiting approval')\n\n// Approval — suspend for a human decision and resume with the answer\nawait workflow.approval('Manager sign-off', { ... })\n```\n\n`workflow.name`, `workflow.runId` and `await workflow.getRun()` identify the\ncurrent run if a step needs to reference it.\n\n### Approval gates: who may answer\n\n`workflow.approval(reason, options)` takes a `schema` (a runtime value — the\npayload arrives from an untrusted caller, so a type generic would validate\nnothing), an optional `expiry`, and an optional policy for **who** may answer:\n\n```typescript\nconst signOff = await workflow.approval('Manager sign-off', {\n schema: SignOffSchema,\n expiry: '3d',\n approvers: 'not-initiator', // four-eyes: anyone but whoever started the run\n approverScope: 'payments:approve', // and they must hold this scope\n})\nif (signOff.status === 'expired') { ... }\n```\n\n`approvers` is one of:\n\n| value | who may answer |\n| ----------------- | -------------------------------------------------------------------------------------------------------- |\n| `any` _(default)_ | anyone the approve entrypoint admits — the gate is a pause for a decision, not an authorization boundary |\n| `owner` | only the user who started the run |\n| `not-initiator` | anyone **except** the user who started the run |\n\nBoth options are enforced in two phases, because a decision can legitimately\narrive before the run has reached the gate:\n\n- **At submission**, if the run has already reached the gate. Reaching it\n publishes the policy into the run state, so the approve entrypoint can judge\n the caller against it and refuse with a **403**.\n- **On replay**, for a decision that arrived before the gate — there was no\n policy to judge it against yet, so it is accepted and judged when the workflow\n reaches the gate. Failing there discards the decision and leaves the gate\n closed, exactly as a decision that fails the schema does.\n\nSo the same rejected decision surfaces as an HTTP error or as a silently\nre-closed gate depending on timing. Both are audited.\n\nA gate declaring neither option accepts a decision from anyone the approve\nroute lets through; gate the route with `auth`/`permissions` to narrow that.\n\n#### What survives the run\n\nA settled decision carries `decidedBy` and `decidedAt`, so the answer keeps its\nprovenance in the step result:\n\n```typescript\nif (signOff.status === 'decided') {\n logger.info(`signed by ${signOff.decidedBy?.userId} at ${signOff.decidedAt}`)\n}\n```\n\nThat record is deleted with the run, though — `deleteRun` cascades to steps and\nhistory — and an attempt that was _refused_ never reaches a step at all. So\nevery answer is also written to the audit sink as `workflow.approval.decided`,\nwith `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the\nrun, reason and refusal in `metadata`. Wire an `audit` service to keep it; a\nproject without one records nothing and is otherwise unaffected.\n\n### Error handling: `onError`, never try/catch\n\n**Do not wrap steps in try/catch.** The DSL extractor serialises the body into a\nstep graph, and a `catch` block is control flow it cannot represent — so the\ngraph would no longer describe what actually runs, which is the whole point of\nthe DSL mode. This is a settled design decision, not a temporary limitation.\n\nUse the `onError` step option instead: it names an RPC to invoke when the step\nhas failed _after_ exhausting its retries.\n\n```typescript\nawait workflow.do(\n 'Charge',\n 'chargePayment',\n { orderId },\n {\n retries: 3,\n retryDelay: '1s',\n onError: 'refundReservation', // compensation RPC\n }\n)\n```\n\nThe handler receives `{ error: { message } }`, and the original error is still\nthrown afterwards — so the workflow still fails. `onError` is **compensation, not\nrecovery**: it exists to undo work, not to swallow the failure and carry on. If\nyou genuinely need to branch on a failure, have the step return a result object\n(`{ success: false, reason }`) and branch on that, the way the `processOrder`\nexample branches on `payment.success`.\n\nFull step options: `description`, `retries`, `retryDelay`, `onError` (plus\n`actor`, which is scenario-only — see `pikku-scenario`).\n\n### Parallel fan-out\n\n```typescript\nconst users = await Promise.all(\n data.userIds.map((userId) =>\n workflow.do(`Fetch user ${userId}`, 'getUser', { userId })\n )\n)\n```\n\n### Graph workflow (DAG)\n\n`pikkuWorkflowGraph` derives types from the RPC map — no explicit `input`/`output`. Nodes map `nodeName → Pikku function name`; `config.<node>.next` lists nodes to run after it (in parallel); `config.<node>.input: (ref) => ...` transforms input using refs to prior node outputs.\n\n```typescript\nimport { pikkuWorkflowGraph } from '#pikku/workflow/pikku-workflow-types.gen.js'\n\nexport const userOnboarding = pikkuWorkflowGraph({\n description: 'Onboard a new user',\n nodes: {\n createProfile: 'createUserProfile',\n sendWelcome: 'sendEmail',\n setupDefaults: 'createDefaultTodos',\n },\n config: {\n createProfile: { next: ['sendWelcome', 'setupDefaults'] }, // run in parallel\n sendWelcome: {\n input: (ref) => ({\n to: ref('createProfile', 'email'),\n subject: 'Welcome!',\n }),\n },\n },\n})\n```\n\n## File conventions\n\n- Place workflows in `packages/functions/src/wirings/*.workflow.ts`; export the variable so the inspector discovers it (no manual registration).\n- HTTP start/run/status routes are auto-scaffolded via `scaffold.workflow` in `pikku.config.json`.\n\n## Step dispatch & HTTP wiring\n\nFor per-step inline-vs-queue dispatch (`workflowQueued: true` and the `dispatchStep` rules), the manual `workflowStart`/`workflow`/`workflowStatus` HTTP wirings, and a suspend/resume example, read `references/workflow-reference.md`.\n\n## After writing\n\n1. `pikku-verify` (codegen + tsc).\n2. PKU641 → a `const`/`let` is inside a block; hoist it to the top of the function body.\n3. Import errors → use `#pikku/workflow/pikku-workflow-types.gen.js`, not `#pikku`.\n4. Type errors only in files you did not touch → pre-existing template errors; safe to ignore.\n5. Both green → call `pikku-workflow-view` with the workflow name.\n", "pikku-workflows-client/SKILL.md": "---\nname: pikku-workflows-client\ndescription: 'Run Pikku workflows from a React frontend and track their progress. Covers `useRunWorkflow` (run-and-wait), `useStartWorkflow` (fire-and-poll), and `useWorkflowStatus` (live status). TRIGGER when: a React component needs to invoke or display the status of a Pikku workflow, the user mentions long-running tasks / background jobs / progress UI tied to a workflow, or asks how to start/track a workflow from the client. DO NOT TRIGGER when: the user is wiring the workflow itself (use pikku-workflow) or only making regular RPC calls (use pikku-react-query).'\ninstallGroups: [core]\n---\n\n# Pikku Workflows — Client Hooks\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\nWhen a project defines any workflow — DSL or `pikkuWorkflowGraph` — three\nReact Query hooks are auto-generated alongside the standard RPC hooks. They handle\nthe two common shapes: **run-and-wait** (short workflows where the\nclient waits for the result) and **fire-and-poll** (long workflows where\nthe client gets a `runId` and polls status).\n\n## Discover what workflows exist\n\n```bash\nyarn pikku meta clients --json | jq '.workflows'\n```\n\nEach entry has `name`, `description`, `mode` (inline | distributed), plus\n`input` / `output` type names. Pass the workflow **name** to the hooks\nbelow.\n\n## Setup\n\nThese hooks are generated into the same `api.gen.ts` as `usePikkuQuery` —\nno extra setup beyond `PikkuProvider` + `QueryClientProvider` (see the\n**pikku-react** and **pikku-react-query** skills).\n\n## `useRunWorkflow(name, options?)` — run and wait\n\nFor short, synchronous-feeling workflows. Returns a mutation that\nresolves to the workflow's output.\n\n```tsx\nimport { useRunWorkflow } from './pikku/api.gen'\n\nfunction ChargeButton({ orderId }: { orderId: string }) {\n const run = useRunWorkflow('chargeOrder', {\n onSuccess: (output) => toast.success(`Charged: $${output.amount}`),\n })\n return (\n <button onClick={() => run.mutate({ orderId })} disabled={run.isPending}>\n {run.isPending ? 'Charging…' : 'Charge'}\n </button>\n )\n}\n```\n\nUse this when the workflow finishes in seconds and the UI can hold open\na loading state until done.\n\n## `useStartWorkflow(name, options?)` — fire-and-poll\n\nReturns a mutation that resolves to `{ runId: string }` immediately. The\nworkflow keeps running on the server. Pair with `useWorkflowStatus` to\nrender progress.\n\n```tsx\nconst start = useStartWorkflow('processVideo', {\n onSuccess: ({ runId }) => setActiveRunId(runId),\n})\n\nstart.mutate({ videoId: '123' })\n```\n\nUse this for long-running workflows (uploads, batch jobs, AI generation,\nanything you'd want a progress bar for).\n\n## `useWorkflowStatus(workflowName, runId, options?)` — observe\n\nPolls the workflow runtime for a run's status. Returns a typed status\nobject with `status`, optional `output`, and optional `error`.\n\n```tsx\nimport { useWorkflowStatus } from './pikku/api.gen'\n\nfunction VideoStatus({ runId }: { runId: string }) {\n const { data: status } = useWorkflowStatus('processVideo', runId, {\n refetchInterval: (query) =>\n query.state.data?.status === 'running' ? 1000 : false,\n })\n\n if (!status) return null\n if (status.status === 'running') return <Spinner />\n if (status.status === 'completed') return <Result {...status.output} />\n if (status.status === 'failed')\n return <Error message={status.error?.message} />\n return null\n}\n```\n\nStatus values: `'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'`.\n\n**Stopping the poll is your job.** The hook adds no terminal-state logic of its\nown — the `refetchInterval` callback above is what ends it, by returning `false`\nonce `status` is no longer `running`. Leave that out and a finished run keeps\nbeing polled forever.\n\nThe hook is disabled until `runId` is set, so passing `undefined` while the run\nhas not started yet is the intended shape rather than something to guard around.\nThat `enabled` is owned by the hook and cannot be overridden through `options`.\n\n## Putting it together — start + observe\n\n```tsx\nfunction ProcessVideoFlow({ videoId }: { videoId: string }) {\n const [runId, setRunId] = useState<string>()\n const start = useStartWorkflow('processVideo', {\n onSuccess: ({ runId }) => setRunId(runId),\n })\n const status = useWorkflowStatus('processVideo', runId)\n\n if (!runId) {\n return (\n <button\n onClick={() => start.mutate({ videoId })}\n disabled={start.isPending}\n >\n Start\n </button>\n )\n }\n return <ProgressBar status={status.data?.status} />\n}\n```\n\n## Backend: streaming richer progress\n\nThe status hook returns a coarse-grained state machine (`running`,\n`completed`, etc.). For step-by-step updates inside a long workflow,\npublish events from the workflow itself via `eventHub` or open a\nWebSocket channel — out of scope for this skill (see workflow + channel\ndocs).\n\n## What NOT to do\n\n- Don't poll status manually with `setInterval` — use `useWorkflowStatus`\n with a `refetchInterval` callback, which dedupes across components and\n lets you stop on a terminal state in one place.\n- Don't call `useRunWorkflow` for workflows that take more than a few\n seconds. The user-facing component will hold a long-running pending\n state with no progress indication; use start + status instead.\n- Don't use these hooks for non-workflow RPCs — they only resolve\n workflow-shaped names. Regular RPCs go through `usePikkuQuery` /\n `usePikkuMutation`.\n", "pikku-ws/SKILL.md": "---\nname: pikku-ws\ndescription: >-\n Use when setting up a WebSocket server with the ws library in a Pikku app. Covers the ws runtime\n adapter for Pikku channels. TRIGGER when: code uses @pikku/ws, user asks about ws library\n WebSocket server, or Node.js WebSocket runtime. DO NOT TRIGGER when: user asks about WebSocket\n wiring/channels (use pikku-websocket) or uWebSockets (use pikku-deploy-uws).\n---\n\n# Pikku WS (WebSocket Server Runtime)\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.\n2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.\n3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.\n4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.\n5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.\n\n`@pikku/ws` provides a WebSocket server runtime using the [ws](https://github.com/websockets/ws) library, connecting Pikku's channel system to a Node.js WebSocket server.\n\n## Installation\n\n```bash\nyarn add @pikku/ws ws\n```\n\n## Usage Patterns\n\n### Basic Setup\n\nThe package exports one function, `pikkuWebsocketHandler` — there is no server\nclass. You own the `http.Server` and the `WebSocketServer`; the handler attaches\nthe upgrade and message plumbing to them.\n\n```typescript\nimport { pikkuWebsocketHandler } from '@pikku/ws'\nimport { stopSingletonServices } from '@pikku/core'\nimport { Server } from 'http'\nimport { WebSocketServer } from 'ws'\n\nimport '../.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n\nconst server = new Server()\nconst wss = new WebSocketServer({ noServer: true })\n\npikkuWebsocketHandler({\n server,\n wss,\n logger: singletonServices.logger,\n logRoutes: true, // print the wired channels at startup\n loadSchemas: true, // compile input schemas up front\n})\n\nserver.listen(4002, 'localhost')\n```\n\n`noServer: true` is not optional decoration — the handler performs the upgrade\nitself so it can run pikku's HTTP middleware chain (auth, cors) against the\nupgrade request before a channel exists. Letting `ws` bind the server directly\nwould skip that.\n\nServices come from the bootstrap import and the global singleton registry, which\nis why nothing is passed in. The event hub is taken from\n`singletonServices.eventHub` when it is a `LocalEventHubService`, and a local one\nis created otherwise — so a single-process app gets pub/sub for free, while a\nmulti-instance deployment must register a distributed hub (see `pikku-realtime`).\n\nThe options type also extends `RunHTTPWiringOptions`, so per-request settings\nsuch as `respondWith404`, `coerceDataFromSchema` and `bubbleErrors` are accepted\nhere too.\n\nOn shutdown, call `stopSingletonServices()` then close `wss` and `server`.\n\nSee `pikku-websocket` for channel wiring details, and\n`pikku-deploy-fastify`/`pikku-deploy-express` when the WebSocket server shares a\nport with an HTTP app.\n" };
|