@pikku/skills 0.12.26 → 0.12.28

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.
@@ -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-a11y/SKILL.md": "---\nname: pikku-a11y\ndescription: >-\n Accessibility rules (WCAG 2.2) for the app UI: labeled inputs, real buttons/links, keyboard and focus, contrast and not-color-alone, modals, reduced motion.\n TRIGGER when: building forms or any interactive UI, icon-only buttons, modals/drawers, tables/lists with actions, keyboard/focus work, or the user mentions accessibility / screen readers / WCAG.\n DO NOT TRIGGER when: working on backend functions, database, or deployment with no UI.\ninstallGroups: [client]\n---\n\n# Accessibility Rules\n\nMantine components are accessible ONLY when used properly — the rules below are the\n\"properly\". They apply to every page; heading order, landmarks, and image alt text are\ncovered in the `pikku-seo` skill and apply app-wide, not just on public pages.\n\n## Every input has a label\n\n- Use the `label` prop on every Mantine input — a placeholder is NOT a label (it\n disappears on input and is never announced as one). Placeholder = example value only.\n- Use the `error` and `description` props for validation/help text — Mantine associates\n them with the input for screen readers; a loose `<Text c=\"red\">` next to the field\n does not.\n- Icon-only controls (`ActionIcon`, icon `Button`) MUST have `aria-label={m.key()}`\n naming the action (\"Delete item\", not \"Trash icon\").\n\n## Interactive = a real button or link\n\n- Never `onClick` on a `div`/`Box`/`Card` — it is invisible to keyboard and screen\n readers. Use `Button`, `ActionIcon`, `UnstyledButton`, or `<Link>`; navigation is a\n link (href), actions are buttons.\n- Everything reachable by Tab, activatable by Enter/Space. Never remove focus outlines\n (the theme owns the focus ring), never set `tabIndex` greater than 0, never trap focus\n yourself.\n- Whole-row/whole-card click: put the button/link INSIDE with the row as its label —\n don't make the container clickable and unfocusable.\n\n## Don't say it with color alone\n\n- Status must carry text or an icon, not only a color: a Badge says \"Overdue\", a form\n error has a message — a red tint by itself is invisible to colorblind users.\n- Contrast comes from the theme; don't undermine it by stacking `c=\"dimmed\"` on small\n text over tinted backgrounds. Body copy stays at least AA-readable.\n- Touch targets: WCAG 2.2 minimum 24px — don't shrink `ActionIcon`/`Checkbox` below\n size `sm`, and keep adjacent row actions spaced.\n\n## Overlays and motion\n\n- Modals/drawers: use Mantine `Modal`/`Drawer` and ALWAYS pass `title` — that is what\n gets announced; focus trap and Escape come built in. (This project uses drawers, not\n dialogs.)\n- Landing-page animation (the only custom-CSS surface) respects\n `prefers-reduced-motion: reduce` — gate transforms/parallax behind the media query.\n\n## Self-check before declaring UI done\n\nTab through the page once: every control reachable and visibly focused, every input\nlabeled, every icon button named, every status readable without color. A browser\nscenario proves the flow works, not that it is reachable without a mouse — this\nmanual pass is the only check that does.\n", "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 # imports -> dist, exports -> dist\n├── pikku.config.json # addon: true + metadata\n├── tsconfig.json # #pikku path mapping (source side)\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/addon/ # Generated (gitignored)\n```\n\nAn addon's generated tree roots one level down, at `.pikku/addon/`, so its own\nleaves are reached as `#pikku/addon/<leaf>` while an application's are\n`#pikku/<leaf>`. `paths` are global to a tsx process rather than scoped to the\npackage that declared them, and the extra segment is what stops a linked addon's\n`#pikku/function` from matching the *host application's* flat leaf.\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`outDir` stays `./.pikku`; `addon: true` is what appends the `addon` segment.\n\n## package.json (key fields)\n\n```json\n{\n \"name\": \"@my-org/addon-todos\",\n \"imports\": {\n \"#pikku/*.js\": \"./dist/.pikku/*.js\",\n \"#pikku/*\": [\"./dist/.pikku/*/index.js\", \"./dist/.pikku/*\"]\n },\n \"exports\": {\n \".\": { \"types\": \"./dist/src/index.d.ts\", \"import\": \"./dist/src/index.js\" },\n \"./.pikku/*\": \"./dist/.pikku/addon/*\",\n \"./.pikku/pikku-metadata.gen.json\": \"./dist/.pikku/addon/pikku-metadata.gen.json\",\n \"./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js\": {\n \"types\": \"./dist/.pikku/addon/rpc/pikku-rpc-wirings-map.internal.gen.d.ts\"\n }\n },\n \"files\": [\"dist\"],\n \"peerDependencies\": {\n \"@pikku/core\": \"*\",\n \"zod\": \"^4\"\n },\n \"scripts\": {\n \"prebuild\": \"pikku all\",\n \"pikku\": \"pikku all\",\n \"build\": \"tsc && cp -r .pikku types dist/\"\n }\n}\n```\n\n**`imports` names `dist`, never the source tree.** `files: [\"dist\"]` is the whole\npublished package, and `build` copies `.pikku` and `types` into it — so a\n`#pikku/*` target under `./.pikku/` resolves for the author and for nobody else.\nIt is a silent break: the addon compiles, packs, installs and then throws\n`Cannot find module '.../.pikku/addon/function/index.ts'` on first import in the\nconsuming app, out of a file the consumer never wrote. The addon's own build\ndoes not read `imports` at all — tsconfig `paths` covers it, which is why the\ntwo maps point at different trees.\n\n**`exports` targets carry the `addon` segment; the subpaths do not.** A consumer\nwrites `@my-org/addon-todos/.pikku/rpc/...`, exactly as it would in an\napplication, and the leaf stays the package's own business.\n\n## tsconfig.json (key fields)\n\n```json\n{\n \"compilerOptions\": {\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"rootDir\": \".\",\n \"outDir\": \"./dist\",\n \"paths\": {\n \"#pikku/*.js\": [\"./.pikku/*.ts\"],\n \"#pikku/*\": [\"./.pikku/*/index.ts\", \"./.pikku/*\"]\n }\n },\n \"include\": [\"src/**/*\", \"types/**/*\", \".pikku/**/*.ts\"],\n \"exclude\": [\"node_modules\", \"dist\", \".pikku/**/*.d.ts\"]\n}\n```\n\n`paths` resolves the source tree because `dist` does not exist yet on the build\nthat creates it. Both patterns are needed: the `.js` one reaches a generated\nfile (`#pikku/addon/variables/pikku-variables.gen.js`), the bare one reaches a\nleaf's barrel (`#pikku/addon/function`). Keep the `.js` pattern first: both keys\nshare the `#pikku/` prefix, and TypeScript takes the first match of the longest\nprefix rather than the most specific pattern. Node sorts by specificity and does\nnot care about the order.\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-wiring) 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/addon'\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/function'\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/function'\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/setup'\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-services`). `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/setup'\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/setup'\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\nAn addon's generated tree roots at `.pikku/addon/`, but its `imports` map points\n`#pikku/*` there, so it authors against the same subpaths an application does —\n`#pikku/function`, `#pikku/http`. The `addon` segment is the package's own\nbusiness, never part of a specifier.\n\n```typescript\n// src/functions/addTodo.function.ts\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\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/addon'\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 } from '#pikku/http'\nimport { ref } from '#pikku/function'\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 } from '#pikku/http'\nimport { ref } from '#pikku/function'\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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\nexport const todoAgent = pikkuAgent({\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-agent` — an addon function is just another `ref()` in `tools`.\n", "pikku-agent/references/agents.md": "# Pikku AI Agent Wiring\n\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### `pikkuAgent(config)`\n\nImport it from the generated agent types file — `#pikku` does not re-export it:\n\n```typescript\nimport { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\npikkuAgent({\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. 'agentStorage')\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 agentMiddleware?: PikkuAgentMiddlewareHooks[],\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-auth`.\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`runAgent` / `streamAgent` from `@pikku/core/agent` are the layer beneath\nthis. Their third argument is `RunAgentParams` (`{ 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 `AgentStreamEvent`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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\nexport const todoAgent = pikkuAgent({\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: 'agentStorage', lastMessages: 20 },\n maxSteps: 10,\n toolChoice: 'auto',\n})\n```\n\n### Scaffold the HTTP surface\n\n```bash\npikku enable agent\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 = pikkuAgent({\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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\nexport const todoAssistant = pikkuAgent({\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: 'agentStorage', 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-agent/references/runner-vercel.md": "# Pikku AI Vercel (Agent Runner)\n\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### `VercelAgentRunner`\n\n```typescript\nimport { VercelAgentRunner } from '@pikku/ai-vercel'\n\nconst runner = new VercelAgentRunner(\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: AgentRunnerParams, channel: AgentStreamChannel): Promise<AgentStepResult>` — Stream AI responses with tool calls\n- `run(params: AgentRunnerParams): Promise<AgentStepResult>` — Execute a single AI step (non-streaming)\n- `transcribe({ model, audio, … })` / `generateSpeech({ model, text, voice, … })` — what `voiceInput`/`voiceOutput` call; see `references/voice.md`\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 { VercelAgentRunner } 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, agentRunner: new VercelAgentRunner(providers) }\n})\n```\n\nThe service key is **`agentRunner`** — 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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\n\nexport const assistant = pikkuAgent({\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 `wireAgent` — agents are declared with `pikkuAgent` from the\ngenerated agent types. See `references/agents.md` 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 VercelAgentRunner({ '*': createMockLlmProvider() })\n```\n", "pikku-agent/references/voice.md": "# Pikku AI Voice (Speech I/O)\n\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/agent`** as two AI middlewares, and the\nspeech models are reached through the `agentRunner` (`transcribe` /\n`generateSpeech`) rather than through separate services. See `references/runner-vercel.md`.\n\n## API Reference\n\n```typescript\nimport { voiceInput, voiceOutput } from '@pikku/core/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 **`agentMiddleware`** array, not a\n`middlewareHooks` option, and the agent is declared with `pikkuAgent` — there\nis no `wireAgent`.\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 `agentRunner.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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { voiceInput, voiceOutput } from '@pikku/core/agent'\n\nexport const voiceAssistant = pikkuAgent({\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 agentMiddleware: [\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-agent/SKILL.md": "---\nname: pikku-agent\ndescription: >-\n Use when building AI agents, chatbots or LLM-powered assistants with Pikku — pikkuAgent, ref()\n tool registration, memory, streaming, tool approval, thread ownership, invocation via rpc.agent,\n the VercelAgentRunner and its provider map, and the voiceInput/voiceOutput middlewares. TRIGGER\n when: code uses pikkuAgent/rpc.agent/runAgent/streamAgent/VercelAgentRunner/voiceInput, user asks\n about AI agents, chatbots, tool-calling, agent memory or streaming, model providers, speech in or\n out, or `pikku enable agent`. DO NOT TRIGGER when: user asks about MCP tool exposure (use\n pikku-wiring), workflows (use pikku-workflow), or general function definitions (use\n pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku AI Agents\n\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: how an\nagent reaches the rest of the app, and which of its knobs mean something other\nthan what they look like.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Defining or invoking an agent — tools, memory, streaming, approval, threads | `references/agents.md` |\n| Wiring the runner, or pointing model strings at a provider or gateway | `references/runner-vercel.md` |\n| Adding speech in or out of an agent | `references/voice.md` |\n\n## An agent is a function that reaches other functions\n\nTools, sub-agents and workflows are all supplied as `ref('domain:funcName')`\nhandles rather than imported values. The inspector resolves each ref against the\ngenerated function map, which is what lets an agent call into another package or\na `graph:*` builtin without an import cycle — and what lets the tool menu be\nfiltered per session before the model ever sees it.\n\nInvoke through `wire.rpc.agent` from inside a Pikku function; it carries the\nsession, the credentials and the RPC depth for you.\n\n## The knobs that do not mean what they look like\n\n- **`goal` is the prompt field, and it is required.** There is no `instructions`\n key. `role`, `personality` and `goal` are concatenated in that order and\n nothing validates which text lands where, so the split buys legibility only.\n- **`output` is honoured only when the agent has no tools.** A structured-output\n schema on a tool-calling agent is silently inert.\n- **`auth` defaults to `false`**, because agents are normally invoked from an\n already-authenticated `pikkuFunc`. `scopes` and `permissions` are enforced\n either way — see `pikku-auth`.\n- **`approvalRequired` sits on the tool function, not on the agent.** The run\n then resolves `status: 'suspended'` with `pendingApprovals`; answer with\n `rpc.agent.approve(runId, approvals)`.\n- **Model strings split on the first slash only** — `provider/model`, so\n `'ollama/qwen2.5:7b'` is fine and a string with no slash throws rather than\n defaulting to a provider.\n\n## What NOT to do\n\n- **Do not trust a caller-supplied `resourceId` as an owner.** It never is: the\n session's principal (`userId`, or `orgId` under `sessionScope: 'org'`) is\n prefixed onto it, so a client sub-partitions inside its own boundary and cannot\n read across one. A sessionless run gets an ephemeral anonymous owner.\n- **Do not rely on the tool menu alone for authorization.** It is two-layer — the\n session decides which tools an agent can see, and the function's own\n `permissions` still guard the call when the model picks one.\n- **Do not point the `'*'` provider at a single vendor.** It resolves every\n provider name with no exact entry, so aimed at one vendor an `anthropic/…`\n string silently reaches OpenAI. Point it at a gateway or a scripted test\n provider — something that genuinely accepts arbitrary model names.\n- **Do not add `@pikku/ai-voice` as a dependency.** It still publishes but its\n entire source is `export {}`. Voice is two middlewares in `@pikku/core/agent`,\n with the speech models reached through the `agentRunner`.\n", "pikku-architect/SKILL.md": "---\nname: pikku-architect\ndescription: >-\n Use to turn one settled milestone note into the technical plan the build is measured against —\n the tables, functions, wires, roles, scopes, screens and scenarios it owes, split into passes and\n written through `pikku knowledge plan set`. This is a SEPARATE SEAT from the build: the plan is\n the denominator `pikku knowledge plan progress` divides by, so whoever writes it must not be the\n one grading themselves against it. TRIGGER when: a milestone note is settled and the next step is\n planning it, the user asks to plan or architect a milestone, `pikku knowledge plan progress` says\n a milestone has no plan, or pikku-build's App mode reaches a milestone with nothing planned. DO\n NOT TRIGGER when: the milestone notes themselves are still being written (use pikku-knowledge),\n the plan already exists and the job is to build it (use pikku-build), or the ask is a one-off\n edit to a working app.\ninstallGroups: [core]\n---\n\n# Plan one milestone\n\nA milestone note says what the app must DO and how it must feel for the person using it. It\ndeliberately does not say how. You are the seat that decides how, once, in writing, before anyone\nbuilds it.\n\n**Why this is a separate seat.** The build agent used to write its own plan. That makes one party\nboth author and examiner: it can build a fraction, plan only that fraction, and certify itself\ncomplete — and `pikku knowledge plan progress` then divides by a denominator the builder chose\nafter seeing its own answer. A plan written here, against the note, by someone who is not going to\nbuild it, is the denominator the builder does not own.\n\n**One milestone, one plan, then stop.** Do not build in this session. Do not plan the next\nmilestone \"while you are here\" — the notes after this one are still allowed to change, and a plan\nwritten against a note that later moves is worse than no plan.\n\n---\n\n## Write nothing by hand\n\nThe plan reaches disk through `pikku knowledge plan set <milestone> <file>` and nowhere else. It\nvalidates first and names the field that is wrong if it refuses; a plan file written with an editor\nis a plan nothing checked, and the place that discovers that is a finished build.\n\nIt is JSON rather than a note on purpose. Everything else under `knowledge/` is prose a human\nreads; this one is consumed field-by-field, and a markdown parser is one more place a misspelt\nheading silently passes. It cannot live INSIDE the milestone note either: that note is frozen once\nits status leaves `proposed`, so rewriting it would change what the builder was told.\n\n## Send it. Do not go looking.\n\n```sh\npikku knowledge plan schema\n```\n\nThat is the specification, in full, with every field's guidance in its `description`. There is no\nsecond plan-format doc. So when you are unsure what a field wants, **write your best honest reading\nand send it** — `plan set` validates every field and names the exact one that is wrong, so a wrong\nguess costs one round trip and teaches you the answer.\n\nThe failure mode to recognise in yourself: you have decided the tables, the passes and the\nfunctions, and you are still reading. That is the moment to run `plan set`.\n\n---\n\n## The turn\n\n### 1. Read what has been settled\n\n```sh\npikku knowledge validate # the base is consistent before you plan against it\npikku meta context --json # what the app already declares\npikku knowledge plan schema # the only spec for what you are about to write\n```\n\nThen read, in the tree: the milestone's own note in full, every note it names on `entities:` and\n`requires:`, the decisions that constrain it, and the migrations already in `db/sqlite/` — those\nsay whether your tables are new or an alter.\n\n**Do not re-interview.** If the note leaves something genuinely undecided, plan the reading that\nbuilds LESS. A smaller milestone that ships is worth more than a complete one that does not, and\nwhat you leave out is named in `covers` for the next milestone to pick up.\n\n### 2. Decide the passes\n\nA pass is a slice of the milestone that stands up on its own. **Pass 1 is a walking skeleton**: it\nreaches a real screen, with real functions behind it, proved by a real browser scenario. Everything\nelse waits behind it.\n\nThis is enforced, not advisory — `plan set` refuses a plan whose pass 1 has no `ui` item, no\n`functions` item, or a pass-1 route with nothing proving it works. The reason is the failure it was\nwritten against: a milestone that built four unwired functions and no page, and reported itself\nfinished. A build that runs out of time in pass 2 has shipped something; one that runs out of time\nhaving built pass 1 across four half-finished layers has shipped nothing.\n\n**Only pass 1 blocks.** `pikku knowledge plan progress` reports a later pass under `deferred` and\nnever refuses on it. That is what stops plan size from being fatal — but it is not licence to plan\na milestone nobody could finish. The question that decides a plan's size is not \"what does this\nnote imply\" but **\"could a build finish all of this if pass 1 took twice as long as I expect\"** — if\nnot, it is two milestones. Plan the first, and say in `covers` what you left behind.\n\n**A screen is what pass 1 reaches only when the milestone IS an app.** The note's `surface:` says\nwhich it is — absent means an app, and `cli`, `mcp`, `agent` and `backend` are the others. On those,\n`ui` is legitimately `n/a` (with its reason, like any slot), and pass 1 proves itself one level\ndown: a pass-1 function that is actually wired, and a `scenarios.backend` item carrying that\nfunction's name in its `fn` field. The obligation never lifts, it only moves — read the surface off\nthe note before you decide the passes.\n\n### 3. Say what each slot is, or say why it is nothing\n\nEvery slot — `model`, `functions`, `roles`, `scopes`, `ui`, and each level of `scenarios` — is\neither `{\"kind\": \"built\", \"description\": ..., \"items\": [...]}` or `{\"kind\": \"n/a\", \"description\":\n...}`. **Both carry prose.**\n\nThere is no way to leave a slot out, and that is the point: \"no roles, because everyone using this\napp is the same kind of person\" and \"nobody thought about roles\" must not look alike. Write the\n`n/a` reason as a sentence a reader would accept, not as the word \"none\".\n\n### 4. Write it\n\n```sh\npikku knowledge plan set <milestone> /tmp/plan.json\n```\n\nWrite the JSON to a file first — the command takes a path, not inline JSON, which is what keeps an\napostrophe in a `description` from ending a shell argument. If it is refused, the refusal names the\nfield path. Fix that field and send it again; do not restructure the plan around a refusal you have\nnot read.\n\nThen confirm what the builder will be handed:\n\n```sh\npikku knowledge plan show <milestone> --for-build\n```\n\n---\n\n## What the plan holds\n\nThe plan holds INTENT. Reality lives in pikku's generated meta under `.pikku/`, which already\ninventories every function, wire, scope, role, workflow, agent and scenario. Nothing here\nduplicates that — only what codegen cannot infer: **why a thing exists, which pass it belongs to,\nand which knowledge note it discharges.**\n\n### `covers` — which notes this milestone discharges\n\nEvery plan claims at least one knowledge note: `note` (its path under `knowledge/`), `hash` (what\nthat note's body hashes to right now) and `complete`.\n\n`complete: false` is the honest answer for a note whose claims span several milestones — claim the\nwhole of a note only when this milestone genuinely leaves nothing of it unbuilt, because a note\nmarked complete is a note nobody looks at again.\n\n**You do not have to compute the hash.** Write anything twelve characters long and send the plan:\n`plan set` refuses a hash that is not the note's current one and names the correct one, so one\nround trip gets you every hash in the plan. That refusal is the point of the field — a hash that\nwas never right makes the note read as edited-since from the moment the milestone ships, and it\ndrops back into a backlog nobody planned.\n\n### `model` — tables, and what their columns HOLD\n\nEach field carries a `classification`: `public`, `internal`, `personal` or `sensitive`. That is what\nlets a permission claim be checked against the data rather than only against itself — a function\nreturning a `personal` column with no permission rule is a defect the gate can name. It is also what\n`db/annotations.ts` ends up expressing, so plan it here rather than discovering it at migrate time.\n\nEach relationship carries `onDelete`: `cascade`, `restrict` or `orphan`. A foreign key states which\nrows are related; it does not state what the product wants when the parent goes, and those three\nproduce identical schemas until someone deletes something. A `cascade` is checked against the\nmigrations by `plan progress`, and needs `provedBy` naming a scenario in this same plan that deletes\nthe parent and asserts the children are gone.\n\nA table that already exists is altered by a NEW forward migration, numbered on from the ones in\n`db/sqlite/`. Editing an applied migration is the hash mismatch that makes a deployed database\nrefuse to migrate, so plan the alter as its own file.\n\n### `functions` — with their wire and their rule on them\n\nThe wire and the permission live ON the function, because that is where pikku enforces them. Two\nparallel lists are two lists that drift.\n\n**Do not give a function a `wire`.** pikku already serves every `expose: true` function as an RPC\nand the client calls it by name, so for nearly every function there is nothing to decide — leave the\nfield out. A `wire` is for the exceptions: its own HTTP path via `wireHTTP` (a webhook, a payment\ncallback, a public URL another system posts to), a queue job, a channel, a scheduled task, or a\nworkflow entry point. Those last two are not alternate URLs — they are what the milestone IS, and a\nplan that omits them ships a `status` column nothing advances or a job nobody runs.\n\n`permission` is a SENTENCE, not a role name — \"only the person who wrote it can edit it\". The roles\nare the engineer's choice; the rule is the part that has to survive being implemented, in the\nfunction's `permissions` field and never in its body. `null` means open to anyone signed in, and\nstating that is different from omitting it. **Every function with a permission rule needs a\npermission scenario naming it in `fn`** — a rule with no failing case is a claim, not a check, and\n`plan set` refuses the plan without one.\n\n### `scopes` — what a KIND of user may do, never who owns a row\n\nA scope depends ONLY on the session: \"may this kind of user do this at all\" — `admin:invoices:void`,\n`billing`. It is declared with `wireScope` and granted in `mapSession`, so every name here has to\nend up in pikku's generated scope meta. One that cannot be declared is one the build can never\nfinish, and `plan progress` refuses the milestone for as long as it stands.\n\nOwnership is not a scope. \"Only the owner of the house may read it\" depends on the row being asked\nfor, and a scope never sees the row — that is the function's `permission` sentence and lives nowhere\nelse. If the rule mentions the record, it is a `permission`; if it reads the same for every row that\nuser touches, it is a scope. An app built from one person's idea usually has none at all, so\n`{\"kind\": \"n/a\", ...}` is the ordinary answer here.\n\n### `roles` — and the app each one signs into\n\nThe distinct `app` values across `roles` ARE the frontends this project gets, and nothing downstream\ncan recover the answer. Colleagues share ONE app and differ by nav and permitted actions (the\nmechanic, the person on the counter, the bookkeeper); someone across the counter with an account\ngets their own (the customer, the tenant, the patient). One app is a real answer and often the right\none — then every role carries the same slug. Never invent a person the notes do not name in order to\nreach two, and never give a slug to someone who never signs in: a guest checking out takes the same\nslug as the seller they buy from, on that app's public routes outside `/app`. Once there is more\nthan one app, every `ui` item carries its `app` too.\n\nAdding the second frontend is the BUILD's job, at the milestone that first needs it —\npikku-build's multi-app reference. Your part is recording which app each person is in.\n\n### `ui` — routes, and what is on them\n\nOne item per route, each with the pass that builds it. A pass-1 route has to be LINKED to the\nscenario that proves it, and there are two ways: name the scenario in that `ui` item's own\n`scenarios` array, or write a browser scenario whose `feature` contains the route path. Nothing else\ncounts — an unlinked browser scenario reads as a route nobody proved, and the plan is refused.\n\n### `scenarios` — keyed by level\n\n`backend`, `browser`, `permission`, each its own slot. Keyed rather than tagged so that a plan with\nfour backend scenarios and no browser scenario fails on its SHAPE — a flat list lets that through,\nand that is exactly the milestone that builds an API and ships no screen.\n\n**Every scenario needs `name`: the `pikkuScenario` export it becomes** (`saveEntryScenario`).\n`feature` and `scenario` are prose for a reader, and prose cannot be matched against codegen.\n`plan progress` looks for the export by that exact name, so a scenario with no name is one the gate\ncannot see.\n\nPermission scenarios default to pass 2 — they harden a journey that has to exist before they can\ncover it — so a role × resource cross product there costs the milestone nothing.\n\n---\n\n## What makes a plan wrong\n\n`plan set` catches the mechanical failures. These are the ones it cannot:\n\n- **A plan for a different milestone.** The note is about `entries`; the plan builds `projects`.\n Every entity the note names must appear in a function or a table.\n- **A pass 1 that is a layer, not a slice.** \"Pass 1: the data model. Pass 2: the API. Pass 3: the\n screens.\" That is three passes of nothing working.\n- **Scenarios that assert the code ran rather than that the person got what they came for.** A\n scenario proving `saveEntry` returns 200 proves the wire. The one worth planning is the one where\n a person writes something, comes back, and it is still there. A browser scenario that opens a page\n and asserts it is still on it proves the route loads and nothing else —\n `pikku knowledge plan progress` names it as a problem and refuses the milestone.\n- **A permission rule invented here.** If the notes do not say who may do a thing, the answer is\n `null` with the reason, not a rule you made up. A rule the user never agreed to is one they find\n out about by being locked out of their own app.\n\n---\n\n## When you are done\n\nThe accepted `plan set` is the end of the seat. Hand the milestone to `pikku-build`, which reads the\nplan with `plan show --for-build`, builds it, and closes the milestone only when\n`pikku knowledge plan progress` is clean. What you wrote is what it is measured against.\n", "pikku-auth/references/better-auth.md": "# 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## 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-meta` 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| read credential values and who holds them | `admin:credentials:read` |\n| set and delete credentials | `admin:credentials:manage` |\n| view declared scopes, roles, and who holds them | `admin:scopes:read` |\n| create roles, change their scopes, grant them | `admin:scopes:manage` |\n| read the audit trail | `admin:audit:read` |\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 read: { description: 'Read credential values and who holds them' },\n manage: { description: 'Set and delete credentials' },\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 scopes: {\n description: 'Authorization management',\n scopes: {\n read: {\n description: 'View declared scopes, roles, and who holds them',\n },\n manage: {\n description:\n 'Create and delete roles, change their scopes, and grant roles to users',\n },\n },\n },\n audit: {\n description: 'The audit trail',\n scopes: {\n read: {\n description:\n 'Read the audit trail — every recorded action, and which user took it',\n },\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### Do not wire better-auth's `admin()` plugin\n\nEvery capability in the table is pikku's own, gated by the scope next to it and\nimplemented against better-auth's internal adapter — `createAuthUser`,\n`setAuthUserPassword`, `setAuthUserBanned`, `deleteAuthUser` and\n`revokeAuthUserSessions`, all exported from `@pikku/better-auth`.\n\n`admin()` would add a second gate on a `user.role` column that pikku otherwise\nignores, which means maintaining two grant systems that have to agree — and the\ncolumn loses, since the scope store is what the rest of the framework reads.\nPikku used to project scopes onto it for exactly that reason; dropping the\nplugin dropped the projection with it.\n\nBanning is the one capability with a schema requirement, and it has its own\nsmall plugin:\n\n```typescript\nimport { pikkuBan } from '@pikku/better-auth'\n\nbetterAuth({ plugins: [pikkuBan()] })\n```\n\n`pikkuBan()` adds `banned`, `banReason` and `banExpires` to `user` and refuses to\ncreate a session for a banned user, lapsing an expired ban as it goes. It makes\nno authorization decision — who may ban is decided by `admin:users:ban` — so it\nnever needs to know about scopes or roles.\n\n### The plugins `@pikku/better-auth` ships\n\nFive, all imported from the package root and passed to `betterAuth({ plugins })`\nlike any other. None is automatic — an app wires the ones it needs.\n\n| Plugin | Plugin `id` | Adds | Use it when |\n| ------------------------ | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------- |\n| `pikkuBan()` | `pikku-ban` | `user.banned/banReason/banExpires` | You ban users (the schema + enforcement half of the above) |\n| `pikkuActor()` | `actor` | `POST /sign-in/actor`, `user.actor` | Scenarios or a dev switcher sign in as a persona |\n| `pikkuCredentialOAuth()` | `credential-oauth` | `POST /credential-oauth/link`, `/credential-oauth/callback/:providerId` | An app links OAuth2 **API credentials** for a user |\n| `pikkuDelegatedAuth()` | `delegated-auth` | `POST /sign-in/delegated` | An imported upstream API is the system of record for identity |\n| `pikkuFabric()` | `fabric` | `POST /sign-in/fabric` | A Fabric-deployed app lets a control-plane operator in |\n\nEvery one carries a `pikku` prefix, because a `plugins: [...]` array mixes these\nwith better-auth's own and a bare `actor()` next to `organization()` says\nnothing about where it came from. The unprefixed names — `ban`, `actor`,\n`credentialOAuth`, `delegatedAuth`, `fabric` — are still exported as deprecated\naliases, so existing apps keep working.\n\nThe plugin's `id` is what better-auth stores; the **export name** is what the\ninspector reads off your `plugins` array and what generated metadata is keyed\nby, so the two differ for every one of them.\n\n#### `pikkuCredentialOAuth()` — link API credentials, not identities\n\n```typescript\npikkuCredentialOAuth({\n config: [\n {\n providerId: 'github',\n type: 'wire',\n clientId,\n clientSecret,\n authorizationUrl,\n tokenUrl,\n scopes: ['repo'],\n },\n { providerId: 'slack', type: 'singleton' /* … */ },\n ],\n scopeService,\n logger,\n})\n```\n\nWraps better-auth's `genericOAuth` to keep its token exchange and refresh, and\nreplaces only the two identity-bound endpoints. `genericOAuth`'s own\n`/oauth2/link` models an identity **provider**: it demands a userinfo response\nand refuses to link when the provider's email differs from the user's. A\ncredential is not an identity — most credential providers expose only\n`/authorize` and `/token` — so the account row is keyed on _whose_ credential it\nis (`accountId` = the linking user's id), making `(providerId, userId)` unique\nby construction. Tokens land in better-auth's `account` table, so\n`auth.api.getAccessToken()` refreshes them on read.\n\n`type` decides the blast radius:\n\n- **`wire`** — every user links their own, and the credential is read on\n the wire that runs as them. Signed in is enough.\n- **`singleton`** — one token the whole app shares, owned by a reserved\n `pikku-platform` user row created on demand. Rebinding it changes the\n credential for _everyone_, so it is gated on `admin:credentials:link` (or the\n `admin` root above it), and **fails closed** with no `ScopeService`. Override\n the whole gate with `canLinkSingleton`.\n\nAn undeclared `providerId` is a 404; an anonymous caller a 401; a refused\nsingleton a 403 that leaves no platform user behind.\n\n#### `pikkuDelegatedAuth()` — the upstream API is the identity provider\n\n```typescript\npikkuDelegatedAuth({\n authenticate: async ({ email, password, apiKey }) => upstream.login(...),\n storeCredential: (userId, identity) =>\n credentialService.set('acme', identity.credential, userId),\n defaultRole: 'member',\n mapRole: (upstreamRole) => ROLE_MAP[upstreamRole],\n scopeService,\n logger,\n})\n```\n\n`POST /sign-in/delegated` forwards the credentials the user already has to\n`authenticate`. On success it JIT-provisions a real user row (email-keyed and\n`emailVerified` — the upstream just verified them), links it via an `account`\nrow (`providerId: 'delegated'`, `accountId: externalId`), persists the upstream\ntoken **before** minting the session, and returns a normal session cookie.\nPasswords are never stored. Exactly one upstream per app: additional imported\nAPIs are linked integrations (`credentialOAuth`), not extra login methods.\n\nA resolved role is granted through the `ScopeService` as a pikku role, so it\nlands in `pikku_user_role` rather than on a column. A role the app never\ndefined is a provisioning gap, not a sign-in failure — the grant is dropped with\na warning and the user still gets in.\n\n`storeCredential` failing, by contrast, **fails the sign-in**: every proxied\ncall would be dead anyway.\n\n#### `pikkuFabric()` — control-plane operator sign-in\n\n```typescript\npikkuFabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY, scopeService, logger })\n```\n\n`POST /sign-in/fabric` verifies a short-lived RS256 token that the Fabric\ncontrol plane signed for an operator session, then signs them into a synthetic\n`fabric-<id>@fabric.internal` row holding the `admin` scope. Asymmetric on\npurpose: the app holds only the public key, so it can never forge an operator\nlogin, and the same `FABRIC_AUTH_PUBLIC_KEY` is distributed to every stage with\nno per-environment secret. A missing or empty key disables the endpoint, and a\ntoken whose `purpose` claim is not `fabric-admin` is rejected. Without a\n`ScopeService` the operator signs in holding nothing.\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/function'\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### Actor sign-in (`actor` plugin)\n\nA different thing from dev quick login, and the one to reach for when \"sign in as\nsomeone\" means **a particular kind of user** rather than one fixed admin.\nRegister it explicitly — it is not automatic:\n\n```typescript\nimport { ACTOR_SIGN_IN_OPT_IN_ENV, pikkuActor } from '@pikku/better-auth'\n\nplugins: [\n pikkuActor({\n secret: SCENARIO_ACTOR_SECRET,\n allowSignIn: await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV),\n }),\n]\n```\n\n`POST ${basePath}/sign-in/actor` `{ email, secret, name? }` → 200 + the normal\nsession cookie. The plugin's `secret` is the **root**, and it may be a (possibly\nasync) function so it can come off the secrets service instead of a captured\nvalue.\n\n**What a caller presents is not the root.** It is\n`deriveActorSecret(root, email)` from `@pikku/core/services` — HKDF-expanded\nHMAC-SHA256 over the lowercased address. The endpoint re-derives the expected\nvalue for the address being signed in as and compares, so a credential minted\nfor one persona is refused for every other, and the root itself is never a valid\ncredential. A root under 32 characters refuses the endpoint outright rather than\nderiving weak credentials from it (the server log names the problem; the client\nis not told which). Callers rarely derive by hand — `pikku dev` mints one per\npersona into `VITE_DEV_ACTOR_SECRETS` for the browser switcher, `pikku persona\nsecret <id>` mints them for a run, and the two `PersonaSignIn` implementations\nderive on the fly.\n\n**Which command is running decides whether it works, not whether a secret is\nset.** `pikku dev` sets `PIKKU_DEV_ACTOR_SIGN_IN` and mints an ephemeral\n`SCENARIO_ACTOR_SECRET` for the run, so local development needs no configuration\nat all. Everywhere else the endpoint refuses (`Actor sign-in is disabled outside\n\\`pikku dev\\``) — `pikku serve` clears the marker outright, so a secret that\nleaked into a production environment enables nothing and gets a warning naming\nitself instead.\n\nA stage that genuinely must run scenarios opts in on purpose, with\n`PIKKU_ALLOW_ACTOR_SIGN_IN=passwordless-actor-sign-in`. Any other value is\nignored and warned about, so the hatch cannot be opened by copying a `true` from\nthe line above.\n\n**Pass it in on any runtime without a populated `process.env`.** The gate reads\nthe environment by default, which is enough for Node but not for a Worker: there\nthe opt-in arrives as a binding and reaches user code through the variables\nservice, so a gate left to `process.env` stays shut on exactly the stages a\ndeployment targets. `allowSignIn` takes the value the caller already read —\n`await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV)` — and is checked against the same\nliteral, near-miss warning included. It is deliberately a value and not a flag:\nwhat opens the gate is still something the deployment set and an operator can\nread back out of it, never something compiled into the bundle. A value passed\nhere is the one consulted, so the environment cannot quietly override what the\nstage was configured with.\n\n**Signing in and provisioning are separate powers.** An unknown address becomes\nan `actor: true` row only under `pikku dev`. With the opt-in set, a stage signs\nin as the personas the deployment provisioned when it started and refuses\neverything else (`No actor account exists for that address`), so holding the\nsecret on such a stage does not let anyone invent identities. Those rows are\nwritten by the fabric plugin when an operator asks to act as an address the\nstage has no account for, so provisioning needs no actor secret and works on a\nstage whose endpoint is shut.\n\n**`SCENARIO_ACTOR_SECRET` is a credential as powerful as the most privileged\npersona.** Provisioning grants declared roles to actor accounts, so an\n`admin` persona is an actor holding real admin — anyone with the secret _and_ the\nopt-in can take a session as one. Do not treat \"actors only\" as a licence to open\nthe hatch in production.\n\nWithin that boundary, three properties bound the damage:\n\n- **It only ever signs in actors.** The plugin adds a `user.actor` boolean\n column; an email matching a row without it is refused with `User is not an\nactor`. So the secret cannot take over a **real user's** account — the blast\n radius is the actor accounts and whatever roles they were granted.\n- **Unknown emails are created only under `pikku dev`**, flagged `actor: true`,\n so a local scenario declaring a new persona needs no seed step. Anywhere else\n the account has to have been provisioned at boot first.\n- **A credential is bound to one address**, so a leaked one is one synthetic\n account rather than the whole actor population; only the root is worth the\n paragraph above. The comparison is constant-time and length-hiding, so a wrong\n credential leaks neither the length nor a prefix of the right one.\n\nThis is the endpoint `pikku scenario` signs its actors in through, and the one\nthe frontend dev switcher posts to — see `pikku-scenario` for declaring the\nactors and `pikku-react` for `useDevActors()`.\n\n### Provisioning personas\n\nAnywhere but `pikku dev`, the accounts have to exist before anyone signs in. The\nstage creates them itself, from the personas you hand `pikkuFabric`:\n\n```ts\nimport { pikkuFabric } from '@pikku/better-auth'\nimport {\n personaConfigs,\n personaEnvironments,\n} from '#pikku/pikku-personas.gen.js'\n\npikkuFabric({\n publicKey,\n audience,\n scopeService,\n personas: {\n personas: personaConfigs,\n environments: personaEnvironments,\n },\n})\n```\n\nThere is nothing else to call and nothing to schedule. The plugin's operator\nendpoint resolves the address the caller wants to act as; a miss provisions the\ndeclaration and looks again. On a stage that already holds the persona that is\none query, and the pass only runs when there is genuinely something absent to\ncreate.\n\n**Do not reach for `pikkuServerLifecycle`'s `afterStart` for this.** That hook is\ninvoked by `pikku serve` and `pikku dev` and by nothing else — no deploy runtime\ncalls it — so a stage on Workers or a serverless target that provisioned from\n`afterStart` provisioned nothing, and every persona signed in holding no roles.\n\nProvisioning runs where the database already is, which is the point: the CLI has\nno connection to a deployed environment's database — it resolves one from the\nlocal project config — so a `pikku persona sync staging` that wrote rows would\nwrite them to whatever database the checkout happened to point at. `pikku persona\nsync <environment>` still exists, and reports who that environment will provision\nand why anyone was skipped, which is what you run _before_ the deploy.\n\nIt creates missing accounts as `actor: true`, applies the roles each persona\ndeclares, and is additive — it never revokes. `PIKKU_ENV` (or an explicit\n`environment`) selects who is eligible, through the same rule that decides who\nmay run there; an address already held by a real, non-actor user throws rather\nthan being granted the persona's roles.\n\n**Deleting a persona does not delete its account.** Being additive leaves a hole:\nthe account keeps every role it was granted, and the actor endpoint authenticates\non the `actor` column alone without consulting the declaration — so an `admin`\npersona nobody declares any more is still a live way in wherever that endpoint is\nopen. By default provisioning warns about those accounts and changes nothing.\n`orphans: 'ban'` shuts them:\n\n```ts\npikkuFabric({\n publicKey,\n audience,\n scopeService,\n personas: {\n personas: personaConfigs,\n environments: personaEnvironments,\n orphans: 'ban',\n },\n})\n```\n\nIt writes the same `banned` column the console's ban RPC writes (so it needs the\n`pikkuBan()` plugin wired, and says so if it isn't), revokes the account's sessions,\nand leaves the row, its grants and its history intact — provisioning lifts the\nban again by itself if the persona comes back. Deleting is deliberately not\noffered: an actor row is referenced by whatever those scenarios did while it\nexisted.\n\n`report` is the default because a rolling deploy runs the new replica's\nprovisioning while the old replica is still serving, so for the length of that\noverlap \"no persona claims this\" is a statement about the newer declaration only.\nA persona pinned to another environment counts as unclaimed here — it has no\nbusiness holding a signable account in an environment its own rule refuses it.\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\n---\n\n## Post-signup side effects\n\nAnything that must happen after a user signs up — a welcome email, seeding a first\nrow, creating a personal organization — goes in `databaseHooks.user.create.after`\ninside the `betterAuth({...})` config. Never a custom signup RPC that writes the\nuser itself: better-auth owns the `user` table, and a second write path desyncs it\nfrom the session bridge.\n\n## Two-factor (2FA / MFA)\n\nTOTP authenticator apps, email/SMS OTP, backup codes and trusted devices are all\nbetter-auth's `twoFactor()` plugin. Never hand-roll TOTP or OTP.\n\n1. **Enable + migrate** — add `twoFactor({ issuer: '<AppName>' })` to the `plugins`\n array in the `pikkuBetterAuth` factory, then generate its schema and apply it as a\n migration like any other table change. Run the CLI at the version of better-auth the\n project actually has installed (`npx @better-auth/cli@<that version> generate`) — a\n bare `npx @better-auth/cli` resolves the latest release, which can emit a schema for\n a library you are not running. `twoFactorSecret` lands on `user`.\n2. **Client** — add `twoFactorClient({ onTwoFactorRedirect() { /* go to /2fa */ } })`\n to `createAuthClient({ plugins: [...] })`, and expose thin wrappers\n (`enable2FA`, `verifyTotp`, `disable2FA`, …) rather than leaking the raw\n `authClient`, same as every other auth call.\n3. **OTP delivery** — `otpOptions.sendOTP` goes through the injected email service\n and a rendered template, not a raw `sendEmail`. Set `storeOTP: 'encrypted'`.\n4. **Sign-in flow** — the challenge is raised on the three CREDENTIAL endpoints:\n `signIn.email`, `signIn.username` and `signIn.phoneNumber`. Check\n `context.data.twoFactorRedirect` in `onSuccess`; if true, route to a `/2fa` page\n and verify via `verifyTotp`/`verifyOtp`/`verifyBackupCode` (`trustDevice: true`\n for a 30-day trusted device). The response also carries `twoFactorMethods`\n (`'totp'` only once that user has a verified secret, `'otp'` whenever\n `otpOptions.sendOTP` is configured) — render the choice from it rather than\n assuming TOTP. The session cookie is only created after verification: the\n credential handler's session is deleted while the challenge is in flight, so a\n hook reading `ctx.context.newSession` after sign-in must null-check it.\n5. **UI** — a QR rendered from `data.totpURI` plus the `data.backupCodes` list.\n Enabling, disabling and regenerating backup codes all require the user's\n password.\n\nTOTP secrets and backup codes are encrypted at rest with the auth secret, and\n`/two-factor/*` is rate-limited (3/10s) out of the box.\n\n**2FA gates credential sign-in only.** Magic link, email OTP and OAuth are not\nmatched by the plugin's hook, so a user with 2FA enabled who signs in through one\nof them is NOT challenged. If every route into the app must be gated, either do not\noffer the passwordless ones to 2FA users or add your own check — enabling the\nplugin does not do it.\n\n## Security hardening\n\nThe `pikkuBetterAuth` factory — where `betterAuth({...})` is built — is the one\nplace to harden. Everything below is a `betterAuth` option, not a pikku one.\n\n- **Secret** — `BETTER_AUTH_SECRET` comes from the injected secrets service\n (`await secrets.getSecret('BETTER_AUTH_SECRET')`), never `process.env`, a\n literal, or a fallback default. 32+ chars, high entropy\n (`openssl rand -base64 32`). Better Auth rejects placeholder secrets in\n production.\n- **Trusted origins** — the `baseURL` origin is auto-trusted, so a single-domain\n app serving its API same-origin needs nothing. Add `trustedOrigins` (or a\n comma-separated `BETTER_AUTH_TRUSTED_ORIGINS` variable; wildcards like\n `*.example.com` allowed) ONLY when the browser origin differs from the API\n origin — embedded, preview, or custom-domain deployments. An untrusted\n `callbackURL`/`redirectTo`/`origin` is a 403.\n- **CSRF** — keep it on (`advanced.disableCSRFCheck: false`, the default). A\n proxy in front of the app must preserve the `/api/auth/*` prefix so origin\n checks still work; do not disable the check to \"fix\" a redirect.\n- **Rate limiting** — on by default in production (100/10s global, 3/10s on\n sign-in/up/change-password). `storage: 'memory'` resets on restart, so a\n deployed app wants `storage: 'database'`. Tighten sensitive routes with\n `customRules`, e.g. `'/sign-in/email': { window: 60, max: 5 }` — the key is matched\n against the path with the base path ALREADY STRIPPED, so a rule written as\n `'/api/auth/sign-in/email'` matches nothing and silently leaves the route on the\n default.\n- **Cookies & sessions** — `httpOnly`, `sameSite: 'lax'` and `path: '/'` are\n unconditional, but `secure` and the `__Secure-` name prefix are NOT: they follow a\n `baseURL` on `https://` (or production, or an explicit\n `advanced.useSecureCookies: true`). A deployment whose TLS terminates at a proxy\n and passes an `http://` baseURL through therefore ships session cookies with no\n `secure` flag — set `useSecureCookies` there rather than assuming. Defaults are\n `session.expiresIn` 7d and\n `updateAge` 1d. Add `freshAge` for sensitive actions, and\n `cookieCache: { strategy: 'jwe' }` if the session carries sensitive data — see\n the cookieCache section above, which you want enabled regardless. Only enable\n `crossSubDomainCookies` if auth is genuinely shared across subdomains.\n- **OAuth tokens** — set `account.encryptOAuthTokens: true` (AES-256-GCM) if you\n store provider tokens to call their APIs later.\n- **Audit** — drive auth events from `databaseHooks` (`session.create.after`,\n `user.update.after` for email changes, `account.create.after` for links) into\n whatever audit service the app injects, never a bespoke audit table wired into\n a function body. Returning `false` from a `before` hook blocks the operation.\n- **Background tasks** — on a serverless target, hand genuinely disposable work\n (analytics, logging) to `advanced.backgroundTasks.handler` → `ctx.waitUntil(promise)`\n so it does not delay the response. Not mail: the default handler is\n `p.catch(() => {})`, so anything that must actually arrive — an invitation, a\n password reset — is lost without a trace if the platform reaps the request first.\n Better Auth sends its own through `runInBackgroundOrAwait`, which awaits when no\n handler is configured; do the same for yours.\n- **Enumeration** — handled already (generic \"Invalid credentials\", dummy work on\n unknown users). Keep your own error copy generic too; never leak \"user not\n found\".\n", "pikku-auth/references/jose.md": "# Pikku Jose (JWT Service)\n\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-services`.\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-auth/references/machine-auth.md": "# 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\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-auth/references/permissions.md": "# Pikku Permissions\n\n## ⛔ FIRST: is the caller a machine with a token? ⛔\n\n**Then this is NOT a permissions problem.** Resolve the token in `addHTTPMiddleware('*')` middleware that calls `setSession`, make the function a `pikkuFunc`, and gate it with `scopes`. A `permissions` check that verifies a bearer token and returns `true` is authentication wearing an authorization hat — and it leaves the function sessionless, so every body still has to work out who called it. See `references/machine-auth.md`. The only exception is a bootstrap endpoint whose caller has no identity yet (a shared-secret registration, a login): that one is sessionless and declares its gate here.\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\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/auth'\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/auth'\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/auth'\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/scopes'\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(\n data.raw,\n http.request.header('stripe-signature'),\n secret\n )\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/auth'\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-auth/references/sessions.md": "# Pikku Security (Authentication & Sessions)\n\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/middleware'\nimport { addHTTPMiddleware } from '#pikku/middleware'\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/auth'\n\nexport const isAuthenticated = pikkuAuth(\n async (_services, session) => !!session\n)\nexport const isVerified = pikkuAuth(\n async (_services, session) => !!session?.emailVerified\n)\n\n// wirings/auth.wiring.ts\nimport { authCookie } from '#pikku/middleware'\nimport { addHTTPMiddleware } from '#pikku/middleware'\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-auth/SKILL.md": "---\nname: pikku-auth\ndescription: >-\n Use for anything about identity in a Pikku app — authenticating a caller (login, logout,\n sessions, cookies, bearer tokens, API keys, JWT, OAuth/social providers, MFA, Better Auth) and\n authorizing one (pikkuPermission, pikkuAuth, scopes, defineScope, global permissions). Covers\n which of the two a problem actually is, the built-in auth strategies, machine-to-machine auth\n and `pikku login`, and the JWT service. TRIGGER when: user asks about login, logout, session,\n cookie auth, bearer tokens, API keys, JWT, Better Auth, social providers, restricting who may\n call a function, resource ownership, roles, scopes, or hits MissingScopeError or\n InvalidSessionError. DO NOT TRIGGER when: user asks about middleware mechanics with no identity\n involved (use pikku-middleware) or about secrets and env vars (use pikku-services).\ninstallGroups: [core]\n---\n\n# Pikku Auth\n\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: which of\nthe two problems you actually have, and what goes wrong in each.\n\n## First: authentication or authorization?\n\nThey are separate gates, and confusing them is the most common mistake in a\nPikku app.\n\n**Authentication** answers _who is calling_. It happens in middleware, before the\nfunction runs, and ends in a call to `setSession`.\n\n**Authorization** answers _may this caller do this_. It is declared on the\nfunction — `scopes` and `permissions` — never checked inside its body.\n\nA `permissions` entry that verifies a bearer token and returns `true` is\nauthentication wearing an authorization hat: it leaves the function sessionless,\nso every body still has to work out who called it. Resolve the credential in\nmiddleware instead.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Restricting who may call a function — ownership, roles, scopes | `references/permissions.md` |\n| Reading or setting the session, or wiring `authBearer`/`authCookie`/`authAPIKey` | `references/sessions.md` |\n| Standing up user sign-in — OAuth, email+password, MFA, organizations | `references/better-auth.md` |\n| Authenticating a CLI, agent, sandbox or worker — API keys, `pikku login` | `references/machine-auth.md` |\n| Configuring the JWT service, or rotating a signing secret | `references/jose.md` |\n\n## All authentication goes through `@pikku/better-auth`\n\nThere is no second auth story. A hand-rolled user table, a bespoke password\nhash, a custom OAuth dance — all of them are the wrong answer, and the console\nwill not work against them. `references/better-auth.md` has the setup; the\nbuilt-in strategies in `references/sessions.md` are how a resolved credential\nbecomes a Pikku session, not a replacement for it.\n\n## The three gates\n\nAuthorization is three independent gates, all of which must pass, in this order:\n\n1. **Scopes** (`scopes`) — AND'd, checked before input validation, fails closed.\n2. **Global permissions** (`addGlobalPermission`) — AND'd, an app-wide baseline.\n3. **The function's own `permissions`** — OR'd groups of AND'd entries.\n\nThey are independent: a broad global gate can never satisfy a function's own\nrequirement, and a scope can only ever narrow access, never grant it.\n\n## What NOT to do\n\n- **Do not check authorization inside `func`.** The `permissions` field is\n visible to the inspector; a check in the body is invisible to `pikku info\n permissions` and to an audit. The one sanctioned exception is\n `permissionsInBody`, which is purely declarative — see\n `references/permissions.md`.\n- **Do not write an \"is signed in\" permission.** `auth: true` (the default on\n `pikkuFunc`) already requires the session. A checker returning `!!session`\n gates nothing.\n- **Do not put `scopes` on a `pikkuSessionlessFunc`.** Scopes fail closed and an\n anonymous caller holds none, so it would reject every caller it exists to\n serve. Gate those with `permissions`, which receive an optional session.\n- **Do not expect the built-in strategies to authenticate a non-HTTP caller.**\n `authBearer`, `authCookie` and `authAPIKey` all step aside when there is no\n HTTP request — a queue job, a scheduled task or a channel message needs its\n session set another way.\n- **Do not share one header between the human and machine paths.**\n `Authorization: Bearer` is the human session; `x-api-key` is the machine key.\n Merging them reintroduces the ambiguity the split exists to remove.\n- **Do not reach for wire-, tag- or route-level permissions.** They were removed\n in #972. Permissions live on the function, plus the optional global gate; tags\n are organizational only.\n", "pikku-build/references/app.md": "# Build a product on open-source Pikku\n\nYou have a scaffolded project with skills installed. This skill owns everything\nfrom here: no Fabric account, no card, no hosted build — while keeping the\nproject shaped so `pikku fabric init` later adopts it with zero rework.\n\n**Four phases, and you do not skip ahead:**\n\n1. Write the knowledge graph — what the app IS, before any code\n2. Declare the people and the apps — personas, roles, frontends\n3. Plan the milestones — the buildable pieces, in dependency order\n4. Implement them one at a time — each proven by a scenario before the next starts\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info functions --verbose --silent` and\n read `AGENTS.md` before your first change. Read `metaLocale` in\n `pikku.config.json` too: it is the language every `description`, `title` and\n step `template` you write must be in (§1a). Identifiers stay English whatever\n it says.\n2. Make the smallest source change that satisfies the task. Keep generated files\n generated — never hand-edit `.pikku/`, `*.gen.*`, or the SDK.\n3. Validate with the narrowest relevant command, then `pikku all` when functions,\n wirings, schemas or generated clients may have changed.\n4. If validation fails, fix the source cause and rerun. Do not paper over\n generated errors by editing generated files.\n\n## 0. Bootstrap, before anything else\n\n```sh\nbunx --bun pikku bootstrap\n```\n\nOne command, run once, now — not later when you start building. It wires the\n`#pikku` import alias the generated code depends on. On a fresh scaffold `.pikku/`\nis empty, so **every command that touches codegen fails until this has run** —\nincluding ones you would reasonably reach for while still planning, like\n`pikku persona list`. Those failures look alarming (`Cannot find module\n'#pikku/workflow/pikku-workflow-types.gen.js'`, `Schema generation failed for 16\nschemas`) and they are nothing but this missing step.\n\n`pikku knowledge index` and `knowledge validate` work without it — which is why\nthe planning phases below are safe either way.\n\n## 1. One more round of questions — then stop asking\n\nAsk **three to five** questions that actually change the schema or the screens,\nin one message. Then stop; do not interview the user.\n\n- **Who uses it — who are the distinct kinds of people?** Ask this however small\n the app is. The answer becomes §3's personas and roles, and you build only the\n roles they name.\n- **What are the two or three core objects?**\n- **What is the main thing someone does on their first visit?** This answer\n becomes the second milestone, not the tenth.\n- **One app or several?** Separate apps on separate hosts, or one app with paths.\n Cheap to answer now, expensive after the routes exist.\n- **What should it look like?** The template ships one theme — \"Neutral\", a\n deliberately unopinionated monochrome scaffold — and **nothing in the\n open-source toolchain will ever replace it for you.** Accept any of: keep\n Neutral (fine for an internal tool, but say so out loud); a direction in words;\n a reference (brand guide, screenshots, a site whose register they want); or\n their own design agent/prompt, whose output you take as the direction.\n- **What language should the app speak, and what language does the team work\n in?** Two answers, not one — see §1a, which is where they go. Ask only if the\n request is not obviously English; a brief written in English about an English\n product answers both.\n\nSkip anything you can decide yourself. If nobody answers, assume one app with\npaths, the roles implied by the request, Neutral, English — and say so.\n\n## 1a. Three languages, and you must not collapse them\n\nA brief saying \"the entire UI is German\" is about **one** of these. Getting this\nwrong has already shipped a project that can never add a second language, so\nsettle all three explicitly before you write code.\n\n| Axis | What it covers | Where it goes |\n| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |\n| **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Commit messages. | Nowhere — **always English**, no setting, not negotiable |\n| **Meta** | `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role and persona descriptions | `metaLocale` in `pikku.config.json`, default `en` |\n| **Product UI** | Every string the app shows a user | `messages/<locale>.json`, and `defaultLocale` for what a first-time visitor opens in |\n\n**Identifiers are English.** The product's market does not change this and\nneither does `metaLocale`. Identifiers are the surface the generated `#pikku/*`\nclients, `pikku info`, the typed RPC map and the Kysely types all bind to, and\nunlike a string an identifier cannot be translated later — renaming one is a\nmigration. A German practice management tool gets `getWorklist`, `case`,\n`event`, not `getUebersicht`, `vorgang`, `ereignis`.\n\n**Meta follows `metaLocale`.** Write the team's answer into `pikku.config.json` in\nthis phase, before there is any meta to be wrong:\n\n```json\n{ \"metaLocale\": \"de\" }\n```\n\nIt exists for the Pikku Console. Meta is the one part of a project the Console\nrenders back to a human, so a team working in German reads their own functions,\nfeatures and scenario reports in German. Default `en` and do not ask when the\nproject is obviously English. **On every later run, read this field first and\nauthor descriptions, titles and step templates in it** — a project whose\n`metaLocale` you ignored reports half in one language and half in another.\n\n**Product UI is the message catalogue.** `messages/<locale>.json` via\n`pikku-i18n`, with `defaultLocale` deciding what a visitor opens in.\n`baseLocale` in `project.inlang/settings.json` **stays `en`**: it names the\nmessage source, the catalogue every other language is cloned from, so a project\nthat repoints it has nothing to translate from and `--add-locale` is broken\nforever.\n\nA German medical portal, correctly:\n\n```jsonc\n// project.inlang/settings.json\n{ \"baseLocale\": \"en\", \"locales\": [\"en\", \"de\"] }\n// apps/app/src/i18n/active.json (or: fabric i18n --default-locale de)\n{ \"defaultLocale\": \"de\" }\n// pikku.config.json\n{ \"metaLocale\": \"de\" }\n```\n\nRecord the two non-obvious answers as a `decisions/` note in §2 — neither is\ndiscoverable from code, and the next agent will otherwise re-derive them wrong.\n\n---\n\n## PHASE 1 — What the app is\n\n## 2. Write the knowledge graph — before any code\n\n`knowledge/` is not documentation you write at the end. It is the record of what\nthe app IS, in the words its users use, and it is the one part of the project\nanother agent picks up and continues from. **Nothing gets built until there is a\nmilestone note to build.**\n\nRead `knowledge/index.md` and the `pikku-knowledge` skill, then write the notes\nfor what the user just told you. A project whose `knowledge/` is still only the\nshipped index is a project nobody can resume.\n\nFive sections, each answering exactly one question:\n\n- `milestones/` — what is one buildable piece, and what proves it works\n (some scaffolds call these `slices/`; follow the name `knowledge/index.md`\n uses — `knowledge validate` accepts either)\n- `entities/` — what a thing IS, in the words users use for it\n- `decisions/` — what was chosen and what that rules out.\n `decisions/security/` for who may reach what, `decisions/design/` for how it\n looks and behaves\n- `questions/` — what you asked and never got an answer to\n- `wishlist/` — what someone wants that nobody has asked you to build\n\nRules that make it a graph rather than a pile of files:\n\n- **A note's path is its identity.** Markdown, YAML frontmatter, `type` required.\n Cross-link notes with plain markdown links — that is what makes it a graph.\n- **Create a section the turn you have a note for it**, with its own `index.md`\n written in the same turn. Never scaffold empty directories, and never leave\n notes flat at the root: a `product.md` and a `glossary.md` at `knowledge/` is\n not a knowledge base, and it leaves the project unbuildable.\n- **A milestone note carries `status`** (`proposed` → `dispatched` → `built`,\n nothing else), **at most three `entities`** (past three it is not one piece —\n split it), and **its scenario as a fenced ` ```gherkin ` block in the third\n person** — `Given 'owner' has no entry`, never `Given I …`. A quoted word\n MEANS a persona, so quote only personas you declare in §3 and write domain\n values bare. That block becomes a real scenario in §7.\n- **Record only what pikku cannot tell you.** Tables, columns, function\n signatures, routes, wirings, permissions and roles are all discoverable with\n `pikku info` / `pikku meta`. Copying them into a note gives you a second copy\n that goes stale. Knowledge is the why: decisions, constraints, what a thing\n means.\n\nThree decisions belong here on day one, because every later choice leans on them\nand none is discoverable from code:\n\n- **How the product is split into apps**, and why (§4) — `decisions/`\n- **What each kind of person may reach**, in domain language — `decisions/security/`\n- **What the app should look like** — the direction from §1 (§8) — `decisions/design/`\n\nThen keep it honest — both must pass, and `validate` is a real gate:\n\n```sh\nbunx --bun pikku knowledge index\nbunx --bun pikku knowledge validate\n```\n\n---\n\n## PHASE 2 — Who it is for, and what it is made of\n\n## 3. Declare the people — personas and roles\n\nThe answer to \"who uses it\" becomes code, in one file:\n`packages/functions/src/personas.ts`. It ships with a single `visitor`; add the\npeople the user named, and the roles they imply.\n\n```typescript\nimport { definePersonas } from '#pikku/scopes/pikku-personas.gen.js'\nimport { defineSystemRole } from '#pikku/scopes'\n\ndefineSystemRole({\n owner: {\n displayName: 'Owner',\n description: 'Runs their own properties — sees only what they own',\n scopes: [],\n },\n tenant: {\n displayName: 'Tenant',\n description: 'Lives in one unit — sees only their own tenancy',\n scopes: [],\n },\n})\n\ndefinePersonas({\n visitor: { name: 'Visitor', jobTitle: 'Synthetic health-check user', account: {} },\n amina: {\n name: 'Amina',\n jobTitle: 'Property owner',\n personality: 'Checks arrears first, every single time',\n roles: ['owner'],\n account: {},\n },\n bilal: {\n name: 'Bilal',\n jobTitle: 'Property owner',\n personality: 'A second owner — exists so \"you see yours, not theirs\" is testable',\n roles: ['owner'],\n account: {},\n },\n chidi: {\n name: 'Chidi',\n jobTitle: 'Tenant',\n personality: 'Reports the boiler, wants to know it was seen',\n roles: ['tenant'],\n account: {},\n },\n})\n```\n\n- **Keep `visitor`.** The shipped scenarios name `actors.visitor`, and PKU677\n requires a browser step's actor to be a literal `actors.<name>`. Removing it\n stops `actors.visitor` type-checking, which fails `pikku all`, and nothing you\n write after that registers.\n- **One `definePersonas` call for the whole project.** Codegen builds the\n `PersonaId` union from it, materialises one scenario actor per person, and\n seeds a user row each. A second call site is a second answer to \"who uses this\n app\".\n- **`roles` is typechecked against `defineSystemRole`.** An undeclared role is a\n build error rather than a runtime surprise.\n- **Never write an email address.** Each is derived from the persona id and\n `scenarios.emailDomain` in `pikku.config.json` — `visitor@actors.local`.\n Hand-writing one is how a run signs in as somebody who was never created.\n- **Declare a second person of the same kind** whenever the rule is ownership\n (`bilal` above). \"You see yours, not theirs\" is not testable with one owner,\n and §7 is where it gets caught.\n- **Build the roles the user's answer produces, no more.** An invented role\n becomes invented screens and invented rules, and it is the user who has to\n live with them.\n- **Roles are what a permission check reads, not where it lives.** The check goes\n in the function's `permissions` field (§6), never in the body. Read the\n `pikku-auth` skill.\n\n`pikku persona list` shows who is declared and `pikku roles audit` reports roles\nthe database still holds that code no longer declares — both need §0's bootstrap\nto have run, and both are worth a look once it has.\n\n**One warning about the scaffold's own notes:** `knowledge/index.md` may claim\nthe people live in `pikku.config.json`, put there by a persona command.\nThat is stale. In this template they live in `personas.ts` as above, and\n`pikku.config.json` carries no personas key at all — only `scenarios.emailDomain`.\nTrust the file you can read over the note describing it.\n\n## 4. Declare the apps — one API, several frontends\n\n### The rule that makes it cheap\n\n**One backend, many frontends. Never fork `packages/functions`.**\n\nEvery app imports the same generated SDK (`@project/functions-sdk`) and calls the\nsame RPCs. What differs is which screens exist and what the shell looks like.\nWhat must NOT differ is the data layer: two copies of a `listProperties` function\nis two places for the permission check to be wrong.\n\n**The app split is presentation, not security.** A tenant app that simply never\nrenders the arrears screen is not access control — it is a hidden button.\nSecurity is the `permissions` field on the function, and it holds whether the\ncaller arrived from the admin origin, the tenant origin, curl, or the generated\nclient. Build the split for clarity, and prove the boundary with a refusal\nscenario in §7.\n\n### Choosing the split\n\n- **Separate apps on separate hosts** (`admin.example.com`, `portal.example.com`)\n — when the two audiences share almost no screens, when they should not see each\n other's brand register, or when one may later ship independently.\n- **One app with paths** (`/app/admin/*`, `/app/portal/*`) — when they share most\n of the shell and the difference is a handful of screens. Cheaper, and honest:\n two nav trees in one app is still two apps to a user.\n\nEither way, write the choice and its reason into `knowledge/decisions/`.\n\n### Adding a second frontend — later, not now\n\nRecording the decision is Phase 2 work. **Creating the directory is not.**\nCloning `apps/app` materialises a folder of copied screens, so it belongs to the\nmilestone that first needs the second app, not to planning.\n\nWhen you get there, read `references/multi-app.md`. It carries the clone, the\n`package.json` edits, the `pikkufabric.config.json` frontends map, the dev-runner\nchange that otherwise silently never starts your second app, the per-frontend\nscenario environments, and how sessions behave across two origins.\n\nWhat Phase 2 owes you now is only this: the split, its reason, and who each app\nserves, written into `knowledge/decisions/`.\n\n---\n\n## PHASE 3 — The plan\n\n## 5. Plan the milestones\n\nTurn the app into an ordered list of buildable pieces, each a note in\n`knowledge/milestones/`, each `status: proposed` with a gherkin block.\n\nWhat a milestone is:\n\n- **One buildable piece, at most three entities.** Past three it is not one piece.\n- **Vertical, not layered.** \"The owner sees this month's arrears\" is a\n milestone — migration, function, screen, scenario. \"Add the database schema\" is\n not; it is a step inside one.\n- **It ends in something a person can do**, in a browser, signed in as a named\n persona. If you cannot write the gherkin, you cannot build it yet — that is a\n `questions/` note, not a milestone.\n\nHow to order them:\n\n1. **The spine first.** The one object everything else hangs off, and the screen\n that proves the app exists at all.\n2. **Then the loop the user named as \"the main thing someone does on their first\n visit.\"** That answer from §1 is the second milestone, not the tenth.\n3. **Then each audience's own surface**, one at a time. With two apps, finish one\n app's spine before starting the other's — a half-built app in each is worse\n than one working app.\n4. **Refusals ride along with the milestone that creates the thing being\n refused**, never as a \"permissions\" milestone at the end. A milestone that\n creates a row and does not say who may not see it is not finished.\n\nNumber the files (`01-…`, `02-…`) so the order is visible in the tree. Then\n`knowledge index && knowledge validate` before you write a line of code.\n\n**Show the user the list before building.** This is the last cheap moment to\nreorder — after §6 the migrations are numbered and the order is concrete.\n\n## 5a. The technical plan — one milestone at a time, before you build it\n\nThe milestone note says what the app must DO. The **plan** says what has to\nexist for it: the tables, functions, wires, roles, scopes, screens and\nscenarios, split into passes. It is JSON, it lives beside the note, and\n`pikku knowledge plan progress` measures the finished build against it.\n\n**Read `pikku-architect` and follow it.** The plan is the denominator the\ncompletion check divides by, so a builder who writes their own plan can build a\nfraction, plan only that fraction, and certify itself complete. Fabric answers\nthat by giving the plan its own seat; here the defence is the ORDER, and it only\nholds if you keep it: the plan is written against the note in its own turn,\nbefore any of the code it measures exists, and is never edited afterwards to\nmatch what you ended up building. An item that will not land is deferred with\nits reason — `plan defer` — not quietly rewritten. Write it before you open a\nmigration:\n\n```sh\npikku knowledge plan schema # the only spec there is\npikku knowledge plan set <milestone> /tmp/plan.json\npikku knowledge plan show <milestone> --for-build # what you then build\n```\n\n**Plan one milestone at a time, at the moment you are about to build it** — not\nall of them here. A plan written against a note that later moves is worse than\nno plan, and everything after the current milestone is still allowed to move.\n\n---\n\n## PHASE 4 — Build\n\n## 6. Implement milestones, one at a time\n\n**Per milestone** — plan it (§5a), set its note to `status: dispatched`, do the\nsix steps, close it out (§6a), set it to `built`. Do not start the next one\nuntil §6a passes, §7 is green for this one *and §7a shows its functions\ncovered*. A stack of half-milestones cannot be reviewed and cannot be handed\nover, and an uncovered function is a half-milestone whether or not the note says\n`built`.\n\n1. **Migration.** SQL in `db/sqlite/` at the project root, numbered on from the\n ones already there. Apply with `bunx --bun pikku db migrate`, which also\n regenerates the Kysely types your functions import.\n2. **Seed.** Demo rows in `db/sqlite-dev-seed.sql`. **There is no seed command** —\n `bunx --bun pikku db reset` is the only thing that applies the file, and it\n wipes, migrates and seeds in one go (`--no-seed` stops after the migration,\n for working on an empty state the test data would hide). Because reset always\n arrives at a database it just wiped, **the seed file is plain `INSERT`s** — no\n `ON CONFLICT DO NOTHING`, no `INSERT OR IGNORE`. Nothing ever applies it\n twice, so it never has to defend itself. It is also **local only** — no deploy\n applies it, so anything the app would be broken without in production is\n configuration and belongs in a migration, not here.\n Do this generously and do it now: an empty app demos badly and critiques\n badly, and you cannot judge a screen's hierarchy, overflow, or truncation\n against zero rows. Seed rows each persona sees differently — with an ownership\n rule that means seeding rows for the *second* owner too.\n3. **Functions.** One `pikkuFunc` per `*.function.ts`. Mark it `expose: true` and\n Pikku generates the typed RPC client and the React Query hooks the UI calls;\n you do NOT write an HTTP route for it. Add `wireHTTP` only for a real REST\n shape (a third-party webhook).\n4. **Regenerate:** `bunx --bun pikku all`\n5. **UI.** Pages in `<app>/src/pages/`, one route file each in `<app>/src/routes/`,\n calling functions through the generated `usePikkuQuery` / `usePikkuMutation`\n hooks from `@project/functions-sdk/pikku/api.gen`. One component per `.tsx`\n file. Compose the kit from `@/components/<Name>` rather than hand-rolling.\n Register the screen in `useNavItems()` — that one file feeds both the desktop\n sidebar and the phone navigation.\n6. **Scenario** (§7), then `status: built`.\n\nRules that are not optional:\n\n- A function's input and output types come from its `input:`/`output:` zod\n schemas. Never pass generic type params, never annotate the return type inline.\n The schema is the type. (Generics XOR schemas — never both.)\n- Auth and permission checks go in the `permissions` field, never in the function\n body. This is what makes §4's app split safe.\n- No `process.env` inside a function. Read config through the injected\n `variables` / `secrets` services; `process.env` belongs only in bootstrap. Every\n secret a function reads needs a matching `defineSecret`, or `pikku all` reports\n PKU951 and nobody knows what to provision at deploy.\n- Let the database type your values, via `db/annotations.ts`. A `BOOLEAN` column\n is derived for you. On SQLite the other two are **not** — add them by hand,\n once, and they are typed AND coerced end-to-end:\n\n ```typescript\n export const classifications = {\n payment: {\n paid_at: { kind: 'date' }, // -> Date, not an ISO string\n metadata: { kind: 'json', tsType: 'PaymentMeta' }, // -> parsed object, not unknown\n },\n }\n ```\n\n A `TIMESTAMP`/`DATETIME`/`DATE` column with no entry types as `string`, and a\n `JSON` column with no entry types as `unknown` (the CLI warns PKU481). Add the\n annotation rather than casting around the generated type. Once the file carries\n manual fields, `db migrate` stops overwriting it.\n- A `z.date()` on a function's **input** arrives over RPC as an ISO string, not a\n `Date`. Normalise before calling date methods on it (`new Date(value)`), or it\n throws `.getTime is not a function` at runtime — schema validation accepts the\n string without converting it.\n- Every user-facing string is a translation key. Never a hardcoded literal. With\n two apps that means two `messages/` directories; a string used by both belongs\n to whichever app renders it, and duplication beats a shared bundle that couples\n the apps together.\n- Surface errors. No empty catch, no swallowed promise. If a mutation can fail,\n render the failure inline next to the control that triggered it — not a toast.\n- An exposed function with no session and no permission is reachable by anyone\n over `POST /rpc/:rpcName` (PKU574). Either gate it or drop `expose: true`.\n\nThen run it:\n\n```sh\nbun run prebuild && bun run dev\n```\n\nThat starts the API on :3000 and every frontend in `pikkufabric.config.json`. A\nfrontend running against a dead API looks exactly like an app bug, so if every\nrequest fails, check that both halves came up.\n\nThe `--bun` in `bunx --bun pikku …` is load-bearing — keep it. Without it the\nCLI's `#!/usr/bin/env node` shebang hands the process to whatever Node is on\nPATH, which fails below Node 24 with `ERR_UNKNOWN_BUILTIN_MODULE: No such\nbuilt-in module: node:sqlite`.\n\nOpen it, sign up, and click through what you built. **HTTP 200 is not evidence.**\nThe pages are client-rendered: the server returns 200 with an empty shell, so a\npage whose component throws still looks fine to `curl`.\n\nThat click-through is a smoke check, and it is the only thing it is. **Do not\nhand-drive a browser tool in place of a test.** Steering Playwright yourself\nproves a page rendered once, on your machine, in an order only you remember —\nnothing about it re-runs, so the next agent inherits a claim rather than a test,\nand a regression lands silently. When a journey is worth driving through the UI,\nit is worth writing as a browser step on §7's scenario and running\n`pikku scenario run local --spawn --run browser`: same clicks, same assertions,\nin the repo, green or red on every future run.\n\n## 6a. Close the milestone against its plan, not against your memory\n\n```sh\npikku knowledge plan progress <milestone>\n```\n\nIt reads §5a's plan and reconciles it against the generated meta under\n`.pikku/` — the function exists or it does not, the route is wired or it is not,\nthe `pikkuScenario` export is there or it is not. Nothing it reports comes from\nwhat anyone claimed, which is the whole reason it replaced a todo list. It exits\nnon-zero while anything in the first pass is missing.\n\nThree things it says, and what each one asks of you:\n\n- **MISSING** — the first pass owes it and the meta cannot see it. Either build\n it, or, if it genuinely belongs to later work, move it out with a reason on\n the record:\n\n ```sh\n pikku knowledge plan defer <milestone> function:sendReminder \\\n -r \"The email service it needs is the next milestone.\"\n ```\n\n **A deferral is capped at two per plan.** Past that, the plan was wrong and the\n milestone is two milestones — say so to the user rather than deferring again.\n What you may never do is drop the item silently: the plan is what the next\n person reads to know what this milestone was for.\n- **PROBLEMS** — something exists but does not do what was planned. A function\n planned as restricted whose meta says `auth: false`; a `cascade` no migration\n declares; a browser scenario that opens a page and asserts it is still on it.\n These are never deferred. Fix the app.\n- **DEFERRED to a later pass** — already accounted for. Reported so it is\n visible, never blocking.\n\n**Do not set the note to `built` while this exits non-zero**, and do not edit the\nplan to match what you built — `plan set` is the architect's seat, and a builder\nrewriting its own denominator is exactly what the split exists to stop.\n\n## 7. Prove it — scenarios\n\nA scenario is a user journey run as one of your personas, over the real\ntransport, with that persona's session. It is the only kind of test worth writing\nhere, because a passing one proves the app works the way a signed-in person\nexperiences it. Three ship in `packages/functions/test/scenarios/` — keep them\ngreen — and every milestone's gherkin block from §5 becomes one more.\n\n```typescript\nimport { pikkuScenario } from '#pikku/scenarios'\n\nexport const tenantReportsAFaultScenario = pikkuScenario<void, { id: string }>({\n title: 'A tenant reports a fault and the owner sees it',\n description: 'The report lands on the owning landlord’s queue, and nobody else’s',\n tags: ['scenario', 'maintenance'],\n func: async (_services, _data, { scenario, actors }) => {\n const report = await scenario.do(\n 'reports a broken boiler',\n 'createMaintenanceReport',\n { summary: 'No hot water' },\n { actor: actors.chidi },\n )\n await scenario.then(\n 'appears on the owner’s queue',\n 'reportShowsOnQueue',\n { id: report.id },\n { actor: actors.amina },\n )\n await scenario.then(\n 'is invisible to the other owner',\n 'reportIsNotVisible',\n { id: report.id },\n { actor: actors.bilal },\n )\n return { id: report.id }\n },\n})\n```\n\n- **`do` takes an RPC name; `given`/`when`/`then` take a declared step.** A step\n is a `pikkuScenarioStep` that says what a person is doing and holds one\n implementation per surface (server-side by default, plus a `browser` one that\n drives the page). Reaching for an RPC name in a `then` will not resolve.\n- **Every scenario must assert.** A ladder of `given`/`when` with no `then` is a\n PKU680 critical — it fails `pikku all`, so it stops codegen rather than a test.\n Coverage counts every step, so without that rule an assertion-free ladder of\n clicks would score a perfect run while checking nothing.\n- **Write the refusals.** The third step above is the whole point of §4: one\n persona reaching for another's row has to be rejected, and that rejection is a\n scenario. It is how you prove access control instead of asserting it.\n- **Add `SCENARIO_ACTOR_SECRET` to `.env`.** `bun run dev` generates that file\n with a `BETTER_AUTH_SECRET` and nothing else, and without the actor secret\n `/api/auth/sign-in/actor` is disabled — every scenario then fails at sign-in,\n before its first step, for a reason that reads like an auth bug.\n- **There is no state reset.** A scenario runs against a live server: scope what\n you create to your own rows and unique ids, and never assume a clean database.\n\nRun them:\n\n```sh\nbunx --bun pikku scenario run local --spawn # server-side, the fast path\nbunx --bun pikku scenario run local --spawn --run browser # the same journeys, driven as a human\nbunx --bun pikku scenario run local-admin --spawn --run browser # the second app\n```\n\n`--spawn` starts and stops the server for the run; drop it if `bun run dev` is\nalready up. The browser pass needs the environment's `appUrl` and a browser\ndriver installed — without them the run fails fast rather than half-running.\n\n### 7a. Coverage — which functions have actually been run\n\nGreen scenarios tell you the journeys you wrote still work. They say nothing\nabout the code you never wrote a journey for, and that gap is invisible without\nmeasuring it:\n\n```sh\nbunx --bun pikku dev --coverage # server, instrumented\nbunx --bun pikku scenario run local --coverage # against that server\n```\n\nThat writes `coverage/scenario-coverage.json` — which functions each journey\nexercised. **A function no scenario touches has never been run by anything but\nyou, by hand, once.** It compiles, it typechecks, `pikku all` is happy, and\nnobody has proven it does what it says.\n\nRun it **as each milestone closes**, not once at the end. Coverage read per\nmilestone is a short list you can act on — the milestone you just built either\ncovered its own functions or it did not. Read for the first time after ten\nmilestones it is a wall of red that nobody triages, and the honest response to a\nwall of red is to ignore it.\n\nEvery gap is one of three things, and naming which is the point of looking:\n\n- **A missing scenario** — the function matters and no journey reaches it. Write\n the journey. Refusal paths dominate this category, because it is the case you\n are least likely to have clicked through by hand.\n- **A function that should not exist** — nothing reaches it because nothing needs\n it. Delete it. An unused exposed function is also reachable over\n `POST /rpc/:rpcName`, so this is a security finding, not only dead weight.\n- **Genuinely deferred** — real, not yet reachable from the UI. Say so in the\n milestone note that will cover it, so the gap is a decision rather than a\n hole.\n\nReport the number when you hand the milestone over. A number nobody says out\nloud is a number nobody acts on.\n\n## 8. Make it look like someone designed it\n\nTwo separate jobs, and conflating them is why open-source builds come out looking\nlike the template:\n\n- **8a. Direction** — deciding what it should look like. **No open-source tool\n does this.** Fabric has `fabric-theme`; you have §1's answer and this section.\n- **8b. Critique** — judging how well the built screens execute that direction.\n `impeccable` does this well, and it is free.\n\nImpeccable audits the design you chose. It will never tell you the app should\nhave looked like something else — it will happily award a clean bill of health to\na perfectly-executed default. Skip 8a and you ship Neutral with good spacing.\n\n### 8a. Author the theme — the step nothing does for you\n\nThe look lives in `packages/mantine-theme`, and it is data, not code:\n\nThe look lives in `packages/mantine-theme`, and it is data, not code — one JSON\nper theme, `active.json` naming the live one. **Read `references/theming.md`** for\nthe file layout, what each field changes, and how to turn a direction in words\ninto a theme.\n\nTwo things that belong here rather than in the reference, because they govern\nevery screen you then build:\n\n**Set the theme once, don't hardcode colours per component.** A screen full of\ninline `color=\"blue\"` and one-off hex values is why apps look templated. Change\nthe theme, not the components — and keep it theme-aware for light and dark.\n\nWith two apps, **share the theme package and vary the register, not the\npalette.** A back-office can be denser and more tabular; a customer-facing app\ncan be roomier and warmer — that is `structure` and layout, not a second `brand`.\nTwo unrelated colour schemes read as two products from two companies.\n\nThen **write the direction into `knowledge/decisions/design/`** — the words the\nuser gave you, what you chose, and what it rules out. The JSON records what the\ntheme is; only the note records why.\n\n### 8b. Compose real components, then critique\n\n**Compose with Mantine's rich components — not tables and text everywhere:**\n\n- **`@mantine/charts`** (Recharts underneath) for overviews — `AreaChart`,\n `BarChart`, `LineChart`, `DonutChart`, `Sparkline`. A metric worth showing is\n worth a chart, not a number in a `Text`.\n- **`@mantine/dates`** for anything time-based — `DatePicker`, `Calendar`,\n `DateTimePicker`, range inputs. Never hand-roll a date field.\n- Composed layouts over flat lists — `Timeline` for history, `Stepper` for\n multi-step progress, `Card` + `SimpleGrid` for a gallery, `RingProgress` for\n completion, `Badge`/`ThemeIcon` for status.\n\nBoth ship in the template's app dependencies. Look each one up in the Mantine\nllms.txt and use the real component.\n\nThen critique it. Free, and works across coding agents:\n\n```sh\nnpx impeccable install # current releases need Node 22.18+\n```\n\nImpeccable scores a screen against interaction heuristics and names what is\nwrong: hierarchy, spacing, type registers, states you forgot. Run it on **every**\nscreen in **every** app, fix what it finds, and re-run the ones you changed.\n\nScreenshot each page and feed it the images. Without them its findings drop to\ninference from source, and it misses real misalignment, contrast, and overflow.\nJudging your own UI from source code is guessing.\n\n**Screenshot at a phone width too (≈390px), not just desktop, and critique\nthose.** A layout that is fine at 1440px routinely breaks at 390 — a table that\noverflows, a row of buttons that wraps into a pile, text jammed against the edge,\na modal taller than the viewport. Mantine gives you the tools (responsive `Grid`,\n`visibleFrom` / `hiddenFrom`, `Stack` instead of `Group` at small sizes); use\nthem. The template already mounts a phone navigation per `AGENTS.md` — pick\n`MobileTabBar` or `MobileNavDrawer` deliberately per app, never both.\n\nThe gate: **no P0 findings left on any screen, in any app, at either width.**\nDon't silence a finding by deleting the feature it is about.\n\n## 9. Ship it, and stay Fabric-ready\n\nWhen every milestone is `built` and the scenarios are green, read\n`references/ship.md`. It carries the open-source deploy paths (`--provider\nstandalone`, `cloudflare`, `aws`), how to serve several frontends behind one\nAPI, the pre-release gate to run, and the contract that keeps `pikku fabric init`\na one-command import later rather than a migration.\n\nTwo things from it are worth knowing before you get there, because they are\ncheaper to honour than to retrofit:\n\n- **Nothing hardcodes a host, a port, or a `process.env` read inside a\n function.** Secrets go through `defineSecret` and the injected `secrets`\n service. This is the most common reason a working local project fails its\n first deploy, on any platform.\n- **Generated files stay generated.** No hand edits to `.pikku/`, `*.gen.*`, or\n the SDK.\n\n## Reference\n\n- `references/multi-app.md` — adding a second frontend (§4), at the milestone\n that needs it\n- `references/theming.md` — authoring the theme (§8a)\n- `references/ship.md` — deploying, and the Fabric-readiness contract (§9)\n- Sibling skills: `pikku-knowledge` (§2), `pikku-auth` (§3),\n `pikku-scenario` (§7, §7a), `pikku-deploy` and `pikku-fabric` (§9)\n- Project conventions written by the template: `AGENTS.md`\n- Doing less than this: `references/quick.md``. Doing more: `references/platform.md``.\n", "pikku-build/references/feature.md": "# 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.\n6. Report anything about pikku itself that cost you time, the moment it happens — see **Report what fought you**.\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**Language rule:** read `metaLocale` in `pikku.config.json` (default `en`). It is\nthe language of every `description`, `title` and step `template` you author —\nthe meta the Pikku Console renders back to the team. It renames nothing:\nfunctions, components, types, files, tables and columns are English in every\nproject, and what the app says to its users is the message catalogue. See\n`pikku-concepts` → _What Language You Write In_.\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/error` — `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-services** 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**: `#pikku` is a namespace, not a module — one subpath per wiring.\n `pikkuFunc` / `pikkuSessionlessFunc` come from `'#pikku/function'`, `wireHTTP`\n from `'#pikku/http'`. 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## Report what fought you\n\nWhen pikku itself is what cost you time, report it with `pikku fabric report`.\nNothing is written to the repo; the finding goes to the linked fabric project\nand the terminal shows you exactly what was sent.\n\n**Report at the moment it happens**, not at the end from memory — a run that\nfalls over never reaches its end. One finding per thing that fought you.\n\n### The ladder\n\n1. **Find the quicker workaround.** The user is paying for their feature, not\n for pikku's health.\n2. **Investigate** only when there is no workaround, or when the user asks why\n something is slow or wrong.\n3. **Report at the depth you already reached.** Never spend extra effort to\n file; never throw away effort you already spent. If the investigation took\n you to the mechanism, the finding says so — named file, named function, what\n is actually happening, and what pikku should do instead.\n\n**Never fix pikku itself.** Not a patch in `node_modules`, not a linked\ncheckout, not a branch in the framework repo. Many agents each patching pikku to\nunblock themselves is many divergent copies and a merge problem nobody signed up\nfor. Work around it in the app, report it, and let the fix happen once.\n\n### What counts\n\nAnything that cost you time and would cost the next person the same. Most of\nthese never produce an error: output that is quietly wrong, a generated type\nthat disagrees with the runtime, a check that passes when it should not, a\nnarrowing you had to write by hand because the framework should have written it.\n**Having to write code the framework should have written for you is a finding.**\n\nSo is anything that only shows up in one place — invisible locally, fatal\ndeployed, or the reverse. Say which, with `--surface`.\n\nNot a finding: a preference, a thing you would have designed differently, or\nbaseline noise that was already failing before you started.\n\n### Two kinds\n\n- `--kind product` — pikku behaved wrongly. Fixing it is a change to the\n framework.\n- `--kind harness` — a skill misled you: it told you to run something that does\n not exist, described a flag that is spelled differently, or contradicted what\n the CLI actually did. Pass `--skill <name>` and `--passage \"<the line or\n section>\"`. This is the most useful kind to file, because it is fixable\n immediately — so file it even when the cost was small.\n\n### When there was no workaround\n\nReport it anyway with `--unresolved`, and put what you tried and how each\nattempt failed in `--tried`. That is what stops the next person walking the same\ndead ends. Tell the user what you did instead — abandoned it, shipped something\ndegraded, or stopped.\n\n`--unresolved` means **no workaround was found**. It does not mean the\nworkaround was unpleasant.\n\n### The command\n\nSend it as JSON on stdin. Most of a finding is prose, and prose carries\napostrophes, quotes, backticks and newlines — each one a shell metacharacter\nbefore it is a character in your sentence. A stack trace passed to `--error`\nbreaks the command at its first newline; a backtick in `--actual` runs whatever\nfollows it. Quote the heredoc delimiter (`<<'EOF'`, never `<<EOF`) so the shell\nleaves the body alone.\n\n```bash\npikku fabric report --stdin <<'EOF'\n{\n \"title\": \"<one-line title>\",\n \"kind\": \"product\",\n \"model\": \"<the model you are>\",\n \"expected\": \"<what you expected pikku to do>\",\n \"actual\": \"<what it did instead>\",\n \"command\": \"<the command you ran>\",\n \"workaround\": \"<what you did instead, inside the app>\"\n}\nEOF\n```\n\nAdd whichever of these you actually have: `error` (the error's message line,\nverbatim), `repro` (the shortest way to reach it again), `proposal` (what pikku\nshould do), `area`, `surface` (`local`, `deployed` or `both`), `cost` (measured\nif you measured it — \"98s vs 20s steady\" ranks; \"slow\" does not), `run` (an id\nshared by every finding from this build), `deployTarget`.\n\nThe same fields exist as flags — `--kind`, `--expected` and so on — for a\nfinding short enough to type. Anything with a newline or a quote in it goes\nthrough `--stdin`.\n\nVersions, platform and package manager are read off the installed tree for you.\nDo not pass them and do not ask the user for them.\n\nReporting never fails a build. A finding that cannot be sent — logged out, or\nfabric unreachable — is held on the machine and goes out with the next report\nthat succeeds, so nothing you file is lost. If it says the finding was queued,\ncarry on with the feature; do not try to fix it, and do not file it again.\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, except\n `pikku fabric report`, which is explicitly permitted\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-build/references/multi-app.md": "# Adding a second frontend\n\nRead this when the split you recorded in Phase 2 is \"separate apps\" and you have\nreached the milestone that needs the second one. **Not before.** Cloning\n`apps/app` materialises a directory of copied screens; doing it during planning\nleaves `pikkufabric.config.json` pointing at an app nobody has designed yet.\n\nIf the split is \"one app with paths\", you never need this file — add route\nsegments under `/app` and give each audience its own entries in `useNavItems()`.\n\n## Deciding it is two apps, not one\n\nThe split you are acting on should already be recorded, but this is the reasoning\nbehind it — and the place people get it wrong is the third case at the bottom.\n\n**A group that comes in through its own front door gets its own app.** A role\n*inside* an app is not that: it changes which nav items and which buttons a person\nsees, and lives in `useNavItems()` and the `permissions` on the function, not in a\nroute subtree.\n\n**The test is which side of the counter they are on.** Colleagues share one app and\ndiffer by nav — the mechanic, the person on the counter, the bookkeeper. Someone\nacross the counter with an account of their own gets their own — the customer, the\ntenant, the patient. One app is a real answer and often the right one.\n\n**The asymmetry that forces a split is sign-up.** Where staff accounts are created\n*for* people and customers create their own, the two need different sign-up, different\nonboarding and different session shape, and bending one app around both costs more\nthan the second app does. Do not collapse two audiences into one app to save a build.\n\nNever invent a person the notes do not name in order to reach two.\n\n### The group that never signs in\n\nSome people use the product with no account at all — ordering from a menu, booking a\ntable, opening an invitation. They are not a third case, and **they do not get their\nown frontend**: an app is built around the personas who sign into it. What they get is\nthe public route space every app already has.\n\n- **`/app/*` is the signed-in application.** One `beforeLoad` on `/app` bounces a\n signed-out visitor to the login. There is no per-route exception.\n- **Every other route is public** — `/`, `/menu`, `/book`, `/r/$code`. No gate, no\n session.\n- **`/` is a landing page and you must write it.** A starter that forwards `/` to\n `/app` does so only because it ships no homepage. Leave the forward in and the\n product's front door is a sign-in form: the anonymous visitor arrives at a login it\n has no account for and never reaches the thing it came for — **while every check\n still passes**, because everything that looks at the app signs in first. This is the\n failure this section exists for.\n\nSo a screen whose users have no account goes at `/menu`, never `/app/menu`.\n\n### The frontend guard is UX and proves nothing\n\nThe bundle is on the origin and the nav is a client-side decision; anyone can read\nboth. The security boundary is the `permissions` field on the function — see\npikku-permissions. Hiding a nav item keeps people out of screens that would confuse\nthem; it never protects data. Never let a hidden UI be the only thing between a user\nand someone else's record: if the invoices nav item is hidden but `listAllInvoices`\nhas no `permissions`, the app is wide open and the nav is decoration.\n\nWorth a scenario each, because they are two different claims: that a mechanic cannot\n*see* the invoices nav item, and that their call to an invoices RPC is *refused*. The\nsecond is the one that catches a `permissions` field nobody wired.\n\n## The clone\n\n```bash\ncp -R apps/app apps/admin\nrm -rf apps/admin/node_modules apps/admin/src/paraglide\n```\n\n`src/paraglide` is compiled from `messages/` by the Vite plugin on first run;\ncopying it forward ships one app's compiled strings inside another.\n\nThen, in order:\n\n### 1. `apps/admin/package.json`\n\n- `name` → `@project/admin`\n- `dev` and `preview` ports → `7105`. Every frontend needs its own, or the second\n one fails to bind and the dev runner looks like it hung.\n- the `--tsBuildInfoFile` path inside the **`tsc` script** → `admin-tsc.tsbuildinfo`.\n In this template it is a CLI flag on that script (`tsc --noEmit --incremental\n --tsBuildInfoFile node_modules/.cache/app-tsc.tsbuildinfo`), not a\n `compilerOptions` entry — `tsconfig.json` needs no change. Left alone, the two\n apps fight over one incremental cache and you get type errors that vanish on a\n clean build: an hour of debugging for a one-word edit.\n\n### 2. `pikkufabric.config.json`\n\nThis file is the source of truth for what apps exist, and it is read whether or\nnot you ever deploy to Fabric.\n\n```json\n{\n \"projectId\": \"__PROJECT_ID__\",\n \"frontends\": {\n \"app\": {\n \"cwd\": \"apps/app\", \"primary\": true, \"deploy\": true, \"kind\": \"ssr\",\n \"dev\": { \"command\": [\"bun\", \"run\", \"dev\"], \"port\": 7104, \"healthPath\": \"/\" },\n \"serves\": \"tenant\",\n \"personas\": [\"visitor\", \"chidi\"]\n },\n \"admin\": {\n \"cwd\": \"apps/admin\", \"primary\": false, \"deploy\": true, \"kind\": \"ssr\",\n \"dev\": { \"command\": [\"bun\", \"run\", \"dev\"], \"port\": 7105, \"healthPath\": \"/\" },\n \"serves\": \"owner\",\n \"personas\": [\"amina\", \"bilal\"]\n }\n }\n}\n```\n\nTwo things to fix while you are in here, not just add:\n\n- **The shipped `app` entry may say `[\"yarn\", \"dev\"]`** while the rest of the\n project is driven with bun. Correct it. A frontend that starts under a package\n manager the project does not use is a failure that only appears on someone\n else's machine.\n- **`serves` and `personas` name real personas.** Every persona should appear\n under exactly one frontend. A persona listed nowhere is a person with no way\n in, and that is a design bug worth seeing now rather than at review.\n\n### 3. The dev runner\n\n`dev.mjs`, under the project's scripts directory, spawns `@project/app` **by\nname** and will silently never start your second app — the frontend simply is not\nthere, with no error to explain it.\n\nMake it read the `frontends` map and spawn one child per entry, rather than\nadding a second hardcoded line. Two sources of truth for \"which apps exist\" is\nthe drift this whole file is trying to avoid.\n\n### 4. `pikku.config.json` → `environments`\n\n`local.appUrl` points at one app. Add an environment per frontend (`local`,\n`local-admin`) so the browser scenario pass can drive either one. A browser\nscenario run against the wrong `appUrl` fails on a missing element and reads like\na UI bug rather than a config one.\n\n### 5. Re-run `bun install`\n\n`apps/*` is already globbed in the root workspaces, so this just links the new\none.\n\n## Sessions across two origins\n\nBetter Auth lives once, at `/api/auth/*`, and every app proxies to it (see\n`vite.config.ts` — `/api/auth` keeps its prefix, everything else under `/api` is\nrewritten to the pikku dev server).\n\n- **In local dev, cookies are scoped by host and ignore the port**, so\n `localhost:7104` and `localhost:7105` share a session. Convenient, and a trap:\n the app boundary is invisible in dev and only the role check is doing work.\n That is the correct design — but do not read a working dev session as evidence\n the permission check exists. The refusal scenario is the evidence.\n- **In production on two subdomains**, the session cookie needs a parent domain\n (`.example.com`) or each app gets its own login. Decide which, set it per the\n `pikku-auth` skill, and record it in `knowledge/decisions/security/`.\n- **Never hardcode a host or port.** The API base resolves to same-origin `/api`.\n\n## Building the second app's screens\n\nSame rules as the first: pages in `apps/admin/src/pages/`, routes in\n`apps/admin/src/routes/`, the same generated hooks from\n`@project/functions-sdk/pikku/api.gen`, its own `useNavItems()`, its own\n`messages/` directory.\n\nA string used by both apps belongs to whichever app renders it. Duplicating it\nbeats a shared bundle that couples the two apps together — the moment they share\na string file, they share a release.\n", "pikku-build/references/platform.md": "# Build a platform showcase on Pikku\n\n**This skill is a delta. `references/app.md` is the base — read it and follow it in\nfull.** Everything there applies: knowledge base first, personas and roles,\nmilestones planned then built one at a time, scenarios, design pass, deploy\ngates, Fabric-readiness. This file adds the surfaces that turn an app into a\ndemonstration of the platform, and says where each one slots into that workflow.\n\nRead `references/app.md` now, then come back. Do not blend the two into one plan —\nthe phases below hang off its phases by number.\n\n## What \"platform\" means here\n\nBreadth is the deliverable. A showcase that does one thing beautifully has\nfailed; a showcase where every surface is a stub has also failed. The bar for\neach surface below: **it does something the app genuinely needs, and a scenario\nproves it.** A cron job that logs \"tick\" is not a schedule — it is a comment.\n\nBudget the extra surfaces at one milestone each. They are not free, and a\nhalf-wired workflow engine is worse than no workflow engine.\n\n## Choosing surfaces — during `references/app.md` §5 (planning)\n\nWhen you plan milestones, each surface below becomes its own milestone note in\n`knowledge/milestones/`, ordered after the spine it depends on.\n\n**Five are required, and if the domain does not motivate them you chose the\nwrong domain:** workflows, schedules, queues, an AI agent, and realtime. They are\nwhat \"platform\" means here, and a showcase missing one of them is a showcase of\nsomething else. Pick a domain that needs all five — that choice is yours to make\nat §1, and it is much cheaper than contriving a use later.\n\nThe rest — MCP, triggers and webhooks, extra locales, contract versioning,\naddons — are chosen on merit. Write the motivation into the note. If you cannot\nname what one of *those* is for in this app, drop it and say why in\n`knowledge/decisions/`: a documented omission is a stronger showcase than a\ncontrived inclusion.\n\nWhat is never acceptable is a required surface present as a stub. A cron job\nthat logs `tick` does not become a schedule by existing, and it is worse than\nthe documented omission because it claims to be finished.\n\nMost surfaces are switched on by the CLI rather than hand-wired:\n\n```sh\nbunx --bun pikku enable workflow # workflow workers\nbunx --bun pikku enable agent # public agent endpoints\nbunx --bun pikku enable events # realtime events channel + SSE stream\nbunx --bun pikku enable remote-rpc # internal RPC queue worker + HTTP endpoint\nbunx --bun pikku enable webhook # outgoing webhook delivery queue worker\nbunx --bun pikku enable scenarios # scenario instrumentation\nbunx --bun pikku enable console # console functions\nbunx --bun pikku enable rpc # public RPC endpoint\n```\n\nEach scaffolds a `*.gen.ts` and wires it. Run the enable, then `pikku all`, then\nwrite the function — not the other way round.\n\n## The surfaces\n\nEach has an installed skill that is authoritative on its API. Read it before\nwriting the wiring; this section says what the surface is *for* and how to prove\nit, not how to call it.\n\n### Workflows — `pikku-workflow`\n\nThe one that most changes how an app is built. A workflow is a durable,\nresumable, multi-step process — approval chains, onboarding, anything that waits\non a human or a timer and must survive a restart.\n\n- **Motivation test:** is there a process here with more than one step and a\n gap in the middle? If every operation completes in one request, you do not need\n workflows and forcing one is noise.\n- **Prove it:** a scenario that starts the workflow, advances it as a second\n persona, and asserts the end state. `pikku-react` covers driving it\n from the UI.\n- Three workflows ship with the template. Read them before writing yours.\n\n### Schedules — `pikku-wiring`\n\nRecurring work: a nightly rollup, a reminder sweep, an expiry pass.\n\n- **Motivation test:** something in the domain becomes true with the passage of\n time rather than a user action. Rent falls due. A trial ends. A report is\n monthly.\n- **Prove it:** invoke the scheduled function directly in a scenario and assert\n its effect. Do not test by waiting.\n\n### Queues — `pikku-wiring`\n\nWork that must happen but not now, and may retry: email fan-out, image\nprocessing, third-party calls that fail.\n\n- **Motivation test:** an operation the user should not wait for, or one that\n fails in ways worth retrying.\n- **Prove it:** enqueue in one scenario step, assert the effect in a `then`.\n\n### An AI agent — `pikku-agent`\n\nThe template ships agent wiring and `@ai-sdk/openai`. An agent that answers\nquestions over the app's own data is the showcase; a general chatbot is not.\n\n- **Give it real tools** — your own exposed RPCs, so it answers from the\n database rather than from the model. `pikku all --strict-meta` fails a tool\n with no description, which is the quality gate here: an undescribed tool is\n offered to the model under its bare name and it will misuse it.\n- **Gate it.** `getAgentThreads` ships exposed and sessionless — PKU574 flags it.\n Deciding who may reach the agent is part of building it.\n- **Prove it** with a scenario that asks something only the database knows.\n- Needs a model key. Read it through the injected `secrets` service with a\n matching `defineSecret`, never `process.env`, or deploy has nothing to\n provision (PKU951).\n\n### Realtime and events — `pikku-wiring`\n\n`pikku enable events` gives a realtime channel plus an SSE stream, and the\ngenerated typed client.\n\n- **Motivation test:** two people looking at the same thing at the same time, or\n a long operation whose progress matters.\n- **Prove it:** a browser scenario is the only honest proof — assert the second\n persona's screen changed without a reload.\n\n### MCP — `pikku-wiring`\n\nExposes functions as Model Context Protocol tools, so an outside agent can drive\nthe app. Cheap once functions exist, and a genuine differentiator to show.\n\n### Triggers and webhooks — `pikku-wiring`, `pikku enable webhook`\n\nInbound triggers and outgoing webhook delivery. This is where `wireHTTP` is\ncorrect rather than a smell: a third-party caller needs a real REST shape.\n\n### Locales — `pikku-i18n`\n\n`references/app.md` already requires every string to be a key. **Here, ship three\nlocales, and make one of them RTL** (`pikku-i18n`). Two LTR locales prove the\nplumbing; an RTL one proves the layout, and it will find real bugs — mirrored\nicons, hardcoded `marginLeft`, a nav that opens on the wrong side.\n\nAdding a language means adding a locale file. If it means touching components,\nthat is the finding.\n\n`baseLocale` stays `en` through all of this — it names the message source that\nevery added locale is cloned from, so three locales is `locales: [\"en\", …]` and\nnever a repointed base. Shipping locales is also not a reason for anything in\nthe code to stop being English: identifiers are English in every project, and\nthe language of `description`/`title`/`template` is `metaLocale` in\n`pikku.config.json`. See `references/app.md` §1a.\n\n### Emails — `pikku-emails`\n\nTemplates in `emails/`, rendered and sent through the injected `email` service,\nlocalised like every other string. The base workflow asks for one; **a showcase\nsends three** — a welcome, a transactional confirmation, and one sent from a\nschedule or queue rather than a request, because that is the interesting path.\n\n### Contract versioning — `pikku-meta`\n\n```sh\nbunx --bun pikku versions init\n```\n\nThe CLI suggests this on every run of a project without it. Versioning a function\ncontract, then changing it, is a short milestone that shows something no\nscaffold demonstrates on its own. `pikku semver` derives the release version by\ncomparing this build's surface against a deployed one.\n\n### Addons — `pikku-addon`\n\n`pikku new addon` scaffolds a publishable addon package. Worth one milestone if\nthe domain has a piece that genuinely belongs to no single app.\n\n## Coverage — where the bar is higher than the base workflow\n\n`references/app.md` §7a already has the mechanics and the per-milestone habit:\nrun the server instrumented, run the scenarios against it, read\n`coverage/scenario-coverage.json`, and triage every gap as a missing scenario, a\nfunction that should not exist, or a documented deferral. Do all of that here.\n\nTwo things change in a showcase:\n\n- **Every surface you enabled has to appear in the coverage, not just every\n function.** A workflow, a schedule, a queue worker and an agent each run on\n their own path; a green scenario suite that never advances the workflow past\n step one is the difference between \"the platform does workflows\" and \"there is\n a workflow file in this repo\". Check the surfaces by name, because a coverage\n percentage in the nineties hides an entire unexercised surface comfortably.\n- **The number is part of the deliverable.** A showcase is read as evidence, so\n publish the figure alongside it. An unreported number invites the reader to\n assume the worst, and in a demo repo they are usually right to.\n\n## The full gate\n\nEverything in `references/app.md` §9, plus the checks a showcase should be able to\nsurvive:\n\n```sh\nbunx --bun pikku all --tsc-summary --fail-on-warn --strict-meta\nbunx --bun pikku all --security --fail-on-error\nbunx --bun pikku validate\nbunx --bun pikku knowledge validate\nbunx --bun pikku audit\nbunx --bun pikku scenario run local --spawn --coverage\nbunx --bun pikku scenario run local --spawn --run browser\n```\n\n- `--strict-meta` fails an agent tool with no description.\n- `--security` runs the data-classification lint over function return types,\n catching a `Pii`/`Secret` field leaking through an exposed function. Expensive;\n worth it here.\n- `pikku audit` reports dependency advisories (`--outdated` adds available\n updates) into `.pikku/audit.json`.\n\n## Deploy\n\n`references/app.md` §9 covers the open-source paths (`--provider standalone`,\n`cloudflare`, `aws`). One thing specific to this mode: **the extra surfaces are\nextra deploy units.** Workflow workers, queue workers, schedules and the events\nchannel each appear in `pikku deploy plan` as their own entries. Read the plan\nbefore applying — that list is also the clearest inventory of what you actually\nbuilt.\n\n## Reference\n\n- Base workflow: `references/app.md` — read it first, follow it in full\n- Per-surface skills: `pikku-workflow`, `pikku-wiring`, `pikku-agent`,\n `pikku-i18n`, `pikku-emails`,\n `pikku-meta`, `pikku-addon`, `pikku-auth`, `pikku-services`\n- Every feature, end to end: https://pikkufabric.com/llm-all-features.txt\n", "pikku-build/references/post-clone.md": "# 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.\n\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-build/references/quick.md": "# Build an app on Pikku, fast\n\nYou have a scaffolded project with skills installed. Get it to working, seeded,\nsigned-in screens in as few steps as possible.\n\n**What this mode deliberately skips**, and what that costs:\n\n| Skipped | Cost |\n|---|---|\n| `knowledge/` | Another agent — or you next week — cannot resume this. Nothing records *why*. |\n| Milestone planning | No build order, no per-piece proof. Fine at this size, painful past it. |\n| Design direction | It will look like the template. |\n| Refusal scenarios | Access control is asserted, not proven. |\n\n**Say this out loud to the user, once, when you finish.** A quick build that gets\nmistaken for a real one is the only way this mode does damage. §6 is the way out.\n\n## Agent Operating Procedure\n\n1. Read `AGENTS.md` at the project root before your first screen — routing slots,\n `useNavItems()`, and the shipped component kit.\n2. Keep generated files generated. Never hand-edit `.pikku/`, `*.gen.*`, or the SDK.\n3. Run `pikku all` after touching functions, wirings or schemas. It is the gate,\n and its criticals are real.\n\n## 1. One question, then build\n\nAsk **one** thing, and only if the original request left it open: **who uses\nit — one kind of person, or several?** Everything else you decide yourself.\n\n- **One kind** — no roles to declare. The rule is ownership: you see yours, not\n theirs.\n- **Several** — declare a role each in §2 and keep the count honest. An invented\n role becomes invented screens.\n\nDo not ask about design, deployment, or scope. This is the quick mode; the\ndefaults are the point.\n\nDo not ask about language either — take the defaults and note them in §6.\nIdentifiers are English in every project, whatever the product's market;\n`metaLocale` in `pikku.config.json` (the language of `description`/`title`/step\n`template`, which the Console renders) stays `en` unless the user already told\nyou otherwise. If the request says the app's UI is not English, that is the\nmessage catalogue only: add the locale and set `defaultLocale`, and leave\n`baseLocale` at `en`. `references/app.md` §1a has the three axes in full; getting\nthem confused is how a project ends up unable to add a second language.\n\n## 2. Personas — 60 seconds, not optional\n\n`packages/functions/src/personas.ts` ships with a `visitor`. Add one persona per\nkind of person, plus **a second one of the primary kind** — that is what makes\n\"you see yours, not theirs\" observable when you click around.\n\n**One kind of person** — no `defineSystemRole` at all. Ownership is the only\nrule, and it lives in each function's `permissions`, not in a role:\n\n```typescript\nimport { definePersonas } from '#pikku/scopes/pikku-personas.gen.js'\n\ndefinePersonas({\n visitor: { name: 'Visitor', jobTitle: 'Synthetic health-check user', account: {} },\n amina: { name: 'Amina', jobTitle: 'Gardener', account: {} },\n bilal: { name: 'Bilal', jobTitle: 'Gardener', account: {} },\n})\n```\n\n**Several kinds** — one role each, and only for the kinds the user actually\nnamed:\n\n```typescript\nimport { definePersonas } from '#pikku/scopes/pikku-personas.gen.js'\nimport { defineSystemRole } from '#pikku/scopes'\n\ndefineSystemRole({\n owner: { displayName: 'Owner', description: 'Sees only their own rows', scopes: [] },\n tenant: { displayName: 'Tenant', description: 'Sees only their own tenancy', scopes: [] },\n})\n\ndefinePersonas({\n visitor: { name: 'Visitor', jobTitle: 'Synthetic health-check user', account: {} },\n amina: { name: 'Amina', jobTitle: 'Owner', roles: ['owner'], account: {} },\n bilal: { name: 'Bilal', jobTitle: 'Owner', roles: ['owner'], account: {} },\n chidi: { name: 'Chidi', jobTitle: 'Tenant', roles: ['tenant'], account: {} },\n})\n```\n\nTwo owners in both examples, deliberately: one owner cannot demonstrate that\nowners are separated from each other.\n\n- **Keep `visitor`.** The shipped scenarios name `actors.visitor`; removing it\n fails `pikku all` and nothing you write after that registers.\n- **One `definePersonas` call for the whole project.**\n- **Never write an email address** — each is derived from the persona id and\n `scenarios.emailDomain` in `pikku.config.json`.\n- `roles` is typechecked against `defineSystemRole`; an undeclared role is a\n build error.\n\n## 3. Build\n\nRun `bunx --bun pikku bootstrap` once first. It wires the `#pikku` import alias\ncodegen depends on; without it your first `db migrate` fails with\n`Cannot find package '#pikku'`.\n\nThen, in this order — it is the order codegen depends on:\n\n1. **Migration** — SQL in `db/sqlite/`, numbered on from what is there. Apply\n with `bunx --bun pikku db migrate`, which regenerates the Kysely types.\n2. **Seed** — rows in `db/sqlite-dev-seed.sql`. There is no seed command:\n `bunx --bun pikku db reset` wipes, migrates and seeds in one go, and is the\n only thing that applies the file. It always starts from a wiped database, so\n the file is plain `INSERT`s — no `ON CONFLICT DO NOTHING`. It is local only:\n no deploy applies it, so anything the app cannot run without belongs in a\n migration instead. **Be generous, and\n seed rows for both personas.** An empty app demos badly, and you cannot see a\n layout break against zero rows.\n3. **Functions** — one `pikkuFunc` per `*.function.ts`, `expose: true`. Pikku\n generates the typed RPC client and React Query hooks; you do NOT write HTTP\n routes. `wireHTTP` only for a real REST shape (a third-party webhook).\n4. `bunx --bun pikku all`\n5. **UI** — pages in `apps/app/src/pages/`, one route file each in\n `apps/app/src/routes/`, calling `usePikkuQuery` / `usePikkuMutation` from\n `@project/functions-sdk/pikku/api.gen`. Compose `@/components/<Name>` —\n `PageHeader`, `Panel`, `StatGrid`, `DataTable` — rather than hand-rolling.\n Register each screen in `useNavItems()`; that one file feeds the desktop\n sidebar and the phone navigation.\n\n**Aim for two or three real entities and three screens** — a working surface, a\ndetail view, and somewhere to land. One table with a form on it is not an app,\nand it is not faster to build.\n\nRules that stay non-negotiable even here, because breaking them costs more time\nthan they save:\n\n- Input and output types come from `input:`/`output:` zod schemas. Never generic\n type params, never an inline return type. The schema is the type.\n- Permission checks go in the `permissions` field, never the function body. An\n exposed function with no session and no permission is reachable by anyone over\n `POST /rpc/:rpcName` (PKU574).\n- No `process.env` inside a function — use the injected `variables` / `secrets`\n services.\n- A `z.date()` **input** arrives over RPC as an ISO string, not a `Date`.\n `new Date(value)` before calling date methods, or it throws\n `.getTime is not a function` at runtime.\n- On SQLite, `db/annotations.ts` is where a `DATETIME` becomes a `Date` and a\n `JSON` column becomes a typed object. Without an entry they are `string` and\n `unknown` (PKU481). Add the annotation rather than casting.\n- Surface errors inline next to the control that failed. No empty catch.\n- Every user-facing string is a translation key, not a literal. It is one extra\n keystroke now and a rewrite later.\n- Never hardcode a host or port — the API base resolves to same-origin `/api`.\n\nThen run it:\n\n```sh\nbun run prebuild && bun run dev\n```\n\nAPI on :3000, app on the port vite prints. A frontend against a dead API looks\nexactly like an app bug, so if every request fails, check both came up.\n\n## 4. Look at it — actually\n\nSign up, click every screen. **HTTP 200 is not evidence:** pages are\nclient-rendered, so the server returns 200 with an empty shell and a page whose\ncomponent throws still looks fine to `curl`.\n\nLooking is for the layout — the part only eyes catch. Assertions belong in the\nsmoke scenario, not in a browser session you steered by hand: that session proves\na screen rendered once, here, and nothing about it re-runs.\n\n**Screenshot at 390px too.** A layout that is fine at 1440 routinely breaks on a\nphone — an overflowing table, a row of buttons wrapped into a pile, a modal\ntaller than the viewport. It is the most likely width your demo gets opened at.\n\nIf you have five spare minutes, `npx impeccable install` (Node 22.18+) scores\neach screen against interaction heuristics and names what is wrong. Feed it\nscreenshots, not source. It will polish the default look; it will not give the\napp a look — that is `references/app.md` §8a.\n\n## 5. One smoke scenario\n\nNot the full ladder — one journey, end to end, as a real persona, so the app has\nat least one thing that stays true.\n\n```typescript\nimport { pikkuScenario } from '#pikku/scenarios'\n\nexport const ownerCreatesAndSeesItScenario = pikkuScenario<void, { id: string }>({\n title: 'An owner creates a thing and sees it',\n tags: ['scenario', 'smoke'],\n func: async (_services, _data, { scenario, actors }) => {\n const row = await scenario.do('creates', 'createThing', { name: 'first' }, { actor: actors.amina })\n await scenario.then('sees it listed', 'thingShowsInList', { id: row.id }, { actor: actors.amina })\n return { id: row.id }\n },\n})\n```\n\n- **`do` takes an RPC name; `given`/`when`/`then` take a declared\n `pikkuScenarioStep`.** An RPC name in a `then` will not resolve.\n- **Every scenario must assert.** A ladder with no `then` is a PKU680 critical —\n it fails `pikku all`, stopping codegen rather than a test.\n- **Add `SCENARIO_ACTOR_SECRET` to `.env`.** `bun run dev` writes that file with\n only a `BETTER_AUTH_SECRET`; without the actor secret\n `/api/auth/sign-in/actor` is disabled and every scenario fails at sign-in, for\n a reason that reads like an auth bug.\n- **There is no state reset** — scope what you create to unique ids.\n\nKeep the three shipped scenarios in `packages/functions/test/scenarios/` green.\n\n```sh\nbunx --bun pikku scenario run local --spawn\n```\n\n## 6. Hand it over honestly\n\nTell the user, in one short paragraph: what runs, what it is seeded with, and\nthat this is a quick build — no knowledge base, no milestones, no design pass,\naccess control clicked-through rather than proven.\n\n**Upgrading to a real build is additive, not a rewrite.** If they want it, switch\nto `references/app.md` and do this, in order:\n\n1. Write `knowledge/` for what already exists — `entities/` for what you built,\n `decisions/` for what you chose silently, `questions/` for what you guessed\n at. Then `pikku knowledge index && pikku knowledge validate`.\n2. Backfill a milestone note per screen you built, at `status: built`, each with\n its gherkin block.\n3. Write the refusal scenarios — the ones proving one persona cannot reach\n another's rows. This is the gap that matters most.\n4. Then pick up `references/app.md` at its §4 (apps) or §5 (milestones) for\n anything new.\n\nNothing built here has to be thrown away to do that — which is the whole reason\nthis mode is allowed to skip those steps in the first place.\n", "pikku-build/references/ship.md": "# Shipping, and staying Fabric-ready\n\nRead this when the milestones are built and the scenarios are green — it is\nthe last phase, and nothing in it is needed before then.\n\n## Ship it — open source, no platform\n\n`pikku deploy` builds and ships without any hosted service:\n\n```sh\nbunx --bun pikku deploy plan --provider standalone --runtime bun\nbunx --bun pikku deploy apply --provider standalone --runtime bun\n```\n\n`standalone` comes from the installed `@pikku/deploy-standalone` adapter: it\nbundles the project into a single unit and emits either a `bundle.js` you run\nwith Node, or a self-contained executable compiled with `bun build --compile`.\n`cloudflare` (the default) and `aws` are the other providers — read the\n`pikku-deploy` skill before using it, as it ships the handler\nfactories the deploy codegen expects, and hand-rolling an `ExportedHandler` is\nhow a worker deploy fails at runtime instead of at build.\n\nAlways run `plan` before `apply`, and read it. It names what will be created,\nupdated and deleted — the deletions are the reason to look.\n\nThe frontends build independently (`bun run build` at the root builds every\nworkspace). Serve each behind its own hostname, and put the API behind `/api` on\n**all of them**, mirroring the Vite proxy from the multi-app reference: `/api/auth/*` keeps its\nprefix, everything else under `/api/*` reaches the pikku server unprefixed. Get\nthis wrong and sign-in fails on one app only, which is a miserable thing to debug.\n\nBefore shipping, run the full gate:\n\n```sh\nbunx --bun pikku all --tsc-summary --fail-on-warn\nbunx --bun pikku validate\nbunx --bun pikku knowledge validate\nbunx --bun pikku scenario run local --spawn --coverage\nbunx --bun pikku scenario run local --spawn --run browser\nbun run build # every frontend workspace, type-checked\n```\n\nKeep `--coverage` on the release run even though you have been reading it per\nmilestone (§7a). Each of those readings only covered the functions that\nmilestone added; this is the first time the whole surface is measured at once,\nand it is where a function orphaned by a later refactor shows up.\n\n**The last two lines are not optional, and one of them is easy to talk yourself\nout of.** The server-side pass proves the functions; it renders nothing. The\npages are client-rendered, so a component that throws still returns HTTP 200\nwith an empty shell — the same trap §6 warns about, and the release gate is\nexactly where it gets shipped past. Run the browser pass, and run it **for every\nenvironment in `pikkufabric.config.json`**, not just the first:\n\n```sh\nbunx --bun pikku scenario run local-admin --spawn --run browser\n```\n\n`bun run build` is what type-checks each frontend (each app's `tsc` script runs\n`--noEmit`). `pikku all --tsc-summary` covers the functions package; it does not\nreach into the apps, so a broken screen passes every pikku command and fails on\nthe deploy.\n\n`pikku all --security --fail-on-error` additionally runs the data-classification\nlint over function return types, catching a `Pii`/`Secret` field that leaks\nthrough an exposed function. Expensive, so run it before a release rather than on\nevery save — but run it.\n\n## Staying Fabric-ready\n\nEverything above is open source. This is the contract that keeps\n`pikku fabric init` a one-command import later, instead of a migration.\n\n- **`pikkufabric.config.json` describes reality.** Every app has an entry with\n the right `cwd`, `port`, `kind` and `dev.command`; exactly one is `primary`;\n `serves` and `personas` name real personas from the personas section. Leave `projectId` as\n `__PROJECT_ID__` — that placeholder means \"unlinked\", and linking the project\n writes the real one. Do not invent a value to make it look configured.\n- **One `definePersonas` call**, every persona reachable through exactly one\n frontend. Fabric materialises these as its virtual users; a persona nobody\n serves imports as a person with no way in.\n- **`knowledge/` passes `validate`, with every milestone at `built`.** This is\n the part Fabric itself reads and continues from.\n- **Every `built` milestone passes `pikku knowledge plan progress`.** A note that\n says `built` is a claim; the plan reconciled against the generated meta is the\n check. Anything the first pass still owes is either built now or deferred with\n its reason on the record; anything the check calls a problem — something that\n exists and does not do what was planned — is fixed, whatever pass it came from,\n because deferring it defers a hole rather than the work.\n- **Every milestone has a passing scenario**, including its refusals.\n- **Permissions live in the `permissions` field**, not in function bodies and not\n in the frontends. A check hidden in a component does not survive a new client.\n- **Nothing hardcodes a host, a port, or a `process.env` read inside a\n function.** Secrets go through `defineSecret` and the injected `secrets`\n service. This is the single most common reason a working local project fails\n its first deploy — on any platform.\n- **Generated files stay generated.** No hand edits to `.pikku/`, `*.gen.*`, or\n the SDK.\n- **`pikku validate` is clean**, and `pikku all` has no critical diagnostics.\n\nWhat you deliberately do NOT do: run any `pikku fabric` subcommand, add a card,\nor link a project. None of it is needed to build, test, critique, or deploy — it\nis needed the day the user wants Fabric to host and build it, and on that day, if\nthis section holds, that day is one command long.\n", "pikku-build/references/theming.md": "# Authoring the theme\n\nRead this at the design step, once you have a direction to turn into a theme —\nwhether the user described one in words, handed you a reference, or ran their own\ndesign step whose output you are implementing.\n\nNothing in the open-source toolchain authors a theme for you. Fabric has the\n`fabric-theme` tool; without it, hand-authoring is the route, and it is a small\njob done in JSON.\n\n## Where the look lives\n\n```\npackages/mantine-theme/\n themes/\n default.json # \"Neutral\" — the shipped scaffold\n index.ts # registers each theme JSON by id\n active.json # { \"id\": \"default\" } — which one is live\n index.ts\n```\n\nEach theme JSON has two halves — `brand` (colours, fonts) and `structure`\n(radius, shadows, spacing, per-component `defaultProps`). To give the product an\nidentity, add `themes/<name>.json`, register it in `themes/index.ts`, and point\n`active.json` at its id.\n\n`themes/index.ts` carries a `Generated by the fabric-theme tool — do not edit by\nhand` banner. **That instruction is about Fabric's generator, not about you.**\nWithout that tool, hand-authoring is the only route, and the file is a three-line\nregistry. Edit it, and replace the banner with a line saying the themes here are\nhand-authored — otherwise the next agent reads the warning and leaves the app on\nNeutral.\n\nTurning §1's answer into a theme:\n\n- **Colour before anything else.** `brand.colors.primary` plus\n `structure.primaryShade` and `autoContrast` carry most of the identity;\n `@mantine/colors-generator` (already a dependency) expands one hex into a full\n scale.\n- **Fonts are the other half of the register**, and the half people skip. A serif\n heading font against a neutral body is a different product from the system\n stack, and it is one field.\n- **`structure.radius` and `structure.shadows` set the temperature.** Sharp\n corners and flat surfaces read technical; large radii and soft shadows read\n consumer. Neutral's `md: 10px` is the middle of the road on purpose.\n- **`structure.components` defaultProps is where a design decision becomes\n automatic** — `Card` with `withBorder` everywhere, `NavLink` as `light`. Put\n the repeated decision here rather than on every instance.\n- **`defaultColorScheme` and `darkColors` are a real choice**, not a toggle to\n leave alone. A tool people live in all day is often better dark by default.\n\nThen **write the direction into `knowledge/decisions/design/`** — the words the\nuser gave you, what you chose, and what it rules out. The JSON records what the\ntheme is; only the note records why.\n\n**Set the theme once, don't hardcode colours per component.** A screen full of\ninline `color=\"blue\"` and one-off hex values is why apps look templated. Change\nthe theme, not the components — and keep it theme-aware for light and dark.\n\nWith two apps, **share the theme package and vary the register, not the\npalette.** A back-office can be denser and more tabular; a customer-facing app\ncan be roomier and warmer — that is `structure` and layout, not a second `brand`.\nTwo unrelated colour schemes read as two products from two companies.\n\n\n## If the user gave you no direction\n\nNeutral is a legitimate answer for an internal tool. But say so out loud when you\nhand the work over — an unremarked default reads as a choice, and the user will\nassume someone decided.\n", "pikku-build/SKILL.md": "---\nname: pikku-build\ndescription: >-\n Use to build on Pikku — turning a fresh scaffold into a working app (quick spike, real product,\n or a showcase that exercises every surface), adding a feature to an app that already exists, and\n the one-off cleanup right after a template is cloned. Covers the knowledge base, personas and\n roles, milestone planning, the scenario that proves each one, theming, multi-app layouts and\n deploying. TRIGGER when: the user asks for an app to be built on Pikku, a freshly scaffolded\n project needs turning into a product, the user asks to add a feature or wire up a new endpoint\n in a working app, or a template was just cloned or scaffolded. DO NOT TRIGGER when: the user\n asks for a one-off edit to an existing function, asks about Pikku concepts (use pikku-concepts),\n or wants one specific surface explained rather than built (use that surface's skill).\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 rm *), Bash(git mv *), Bash(git log *), Bash(git branch *), Bash(yarn pikku fabric report *), Bash(npx --no pikku fabric report *)\nargument-hint: '[feature description]'\ninstallGroups: [core]\n---\n\n# Build on Pikku\n\n## Which mode\n\n| The situation | Read |\n| --- | --- |\n| A template was just cloned or scaffolded, and the tree still looks like one | `references/post-clone.md` first, then come back |\n| A real product, meant to be picked up by someone else | `references/app.md` — the default |\n| A spike, a throwaway demo, an idea nobody has committed to | `references/quick.md` |\n| A showcase meant to exercise every Pikku surface | `references/platform.md`, which is a delta on top of `references/app.md` |\n| A feature added to an app that already has its knowledge base and milestones | `references/feature.md` |\n\n**App is the default.** A small or toy-sounding app does not make it Quick;\nonly an explicit signal of speed or throwaway-ness does. Platform is not \"App\nplus more effort\" — it is App plus a deliberate surface checklist, so read the\nbase first and follow it in full rather than blending the two into one plan.\n\nThe supporting references belong to whichever mode sends you to them:\n`references/multi-app.md` (a second frontend), `references/theming.md`\n(authoring the theme), `references/ship.md` (deploying, and the Fabric-readiness\ncontract).\n\n## Bootstrap before anything else\n\n```sh\nbunx --bun pikku bootstrap\n```\n\nOnce, now — not later when you start building. It wires the `#pikku` alias the\ngenerated code depends on, and on a fresh scaffold **every command that touches\ncodegen fails until it has run**, including ones you would reasonably reach for\nwhile still planning. Those failures look alarming and are nothing but this.\n\n## What holds in every mode\n\n- **The branch and the diff are the contract.** There is no plan JSON. A\n reviewer sees real, compiled, working code: apply is a merge, reject is a\n `git branch -D`.\n- **Discover before editing.** `yarn pikku meta context --json` returns\n functions, wires, middleware, permissions, workflows, `capabilities` and\n `layout` in one call. Fall back to targeted `meta` commands only for a full\n schema or a workflow's steps.\n- **`metaLocale` in `pikku.config.json` is the language of authored meta** —\n every `description`, `title` and step `template` the console renders.\n Identifiers stay English whatever it says, and the product's own language\n lives in `messages/*.json`.\n- **`pikku all` is the gate.** Run it after touching functions, wirings or\n schemas, and treat its criticals as real.\n- **A milestone is planned by a different seat than the one that builds it.**\n The plan — tables, functions, wires, roles, scopes, screens, scenarios, in\n passes — is written through `pikku knowledge plan set` by `pikku-architect`,\n and `pikku knowledge plan progress` measures the build against it from the\n generated meta. A builder who writes its own plan is grading itself.\n\n## What NOT to do\n\n- **Do not skip ahead in App mode.** Knowledge, then people, then milestones,\n then one milestone at a time — planned, built, proven by a scenario, and\n closed against its plan before the next starts. The order is the method.\n- **Do not close a milestone your plan says is unfinished.** Build the missing\n item, or defer it with a reason through `pikku knowledge plan defer`. Never\n edit the plan to match what you built, and never drop an item silently.\n- **Do not let a Quick build be mistaken for a real one.** It skips\n `knowledge/`, milestone planning, design direction and refusal scenarios — say\n so out loud to the user when you finish, and point at the way out.\n- **Do not introduce a wire of a type whose `capabilities.<type>` is `false`**\n unless the user asked for it.\n- **Do not hand-edit generated files** — `.pikku/`, `*.gen.*` or the SDK. Fix the\n source and regenerate.\n- **Do not invent a role.** An invented role becomes invented screens; build only\n the roles the user named.\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-wiring` |\n| **Middleware** (Express/Koa-style) | `pikkuMiddleware` | `pikku-middleware` |\n| **Auth Guard / Auth Middleware** | `authBearer()` / `authCookie()` / `authApiKey()` | `pikku-auth` |\n| **Authorization / Permissions** | `pikkuPermission` / `pikkuAuth` | `pikku-auth` |\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-wiring` |\n| **Job Queue workers** | `wireQueueWorker` | `pikku-wiring` |\n| **Cron / Scheduled tasks** | `wireScheduler` | `pikku-wiring` |\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-services` |\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'] })\naddTagMiddleware('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/error'\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 Use FIRST in any Pikku codebase, before writing an import or reaching for another pikku skill.\n Covers the core mental model, function types, project structure, code generation and testing,\n and how to read `pikku doc` — the API surface of the pikku actually installed here, which also\n indexes which skill teaches each door. TRIGGER when: starting any Pikku task, about to import\n from `#pikku/*`, unsure whether an export exists or what its options are called, choosing which\n pikku skill to load, a build failed on an unknown import or option, or migrating an existing\n backend to Pikku. DO NOT TRIGGER when: the task is not a Pikku project.\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 `pikku doc --ai` for the installed API surface, and the relevant `pikku meta ... --json` for what this project has wired.\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\n## Ask The Installed Pikku, Don't Guess\n\nPikku generates `#pikku/*` imports per project and changes between versions. Anything you\nremember about its API may be from a different version than the one in this directory.\nEverything below is the mental model; `pikku doc` is the API surface, computed when the\ninstalled CLI was built. It needs no config and works outside a project.\n\n**Do not write an import, an export name, or an option key you have not seen in `pikku doc`.**\nA name that looks right and is not costs a full build cycle to discover. If the doc does not\nlist it, it does not exist here — do not reach into `node_modules` or `.pikku` for something\nthat will work anyway.\n\n### Start here, every time\n\n```\npikku doc --ai\n```\n\n≈480 tokens, giving the 20 `#pikku/*` doors grouped by the job they do, and beside each the\nskill that teaches it. Read that routing table as the index to every other pikku skill — it is\ngenerated from the installed version, so it never names a skill for a door that no longer exists.\n\nThen go one of two ways. For **what exists** — the exact export name, its options, its\nsignature — stay in the doc:\n\n```\npikku doc http one door: its exports, each with a signature or a key count\npikku doc wireHTTP one export: signature, every key with what it is for\npikku doc wireHTTP pikkuFunc several topics in one call, rather than one call each\n```\n\nFor **how it fits together** — composition, lifecycle, the generated client — load the skill\nthe routing table named. The doc lists keys; it does not teach patterns.\n\nOn a door screen, `N keys — pikku doc X` means a second call buys you something; an inlined\nsignature means it does not. Error classes carry the HTTP status they are registered with,\nwhich is what decides whether a thrown error becomes a 409 or a 500.\n\n### Two things the doc will not give you\n\n- **Worked examples are sparse.** Most exports show a signature and keys, not usage.\n- **`pikkuFunc` lists keys that belong elsewhere.** `before`, `after`, `skip`, `surfaces` and\n `requiresActor` apply only to scenarios; `workflowQueued`, `workflowRetries` and\n `workflowTimeout` only to a workflow step. One shared config type offers all of them to\n every function — each key says which it belongs to.\n\n`pikku doc` needs `@pikku/cli` 0.12.115 or newer. On an older pin, fall back to the door's\nskill and `pikku meta --json`, and do not guess at names the doc would have given you.\n\n## The CLI commands\n\n`pikku doc` is the API surface — the `#pikku/*` exports. It does **not** list\ncommands, so this table is where they exist. `pikku <command> --help` has the\nflags; the \"Read\" column is the skill that teaches the thing, where one does.\n\n**Generating**\n\n| Command | What it does | Read |\n| ------------------------------------------ | ---------------------------------------------------- | ----------------------------- |\n| `all` | Everything: types, schemas, wirings, clients | this skill |\n| `bootstrap` | Type files only (the setup phase) | this skill |\n| `schemas` | JSON Schemas for function input/output types | this skill |\n| `fetch` / `websocket` / `rpc` / `realtime` | One client each, when you do not want `all` | `pikku-wiring`, `pikku-react` |\n| `react-query` / `tanstack-start` | React Query hooks; the TanStack Start `makeApi` shim | `pikku-react` |\n| `queue-service` | The queue service wrapper | `pikku-wiring` |\n| `openapi` | An OpenAPI spec from the HTTP routes | — |\n| `nextjs` | Next.js backend and HTTP wrappers | `pikku-deploy` |\n| `new` | Scaffold a function or wiring | `pikku-wiring` |\n| `enable` | Turn a Pikku feature on | `pikku-build` |\n| `import` | Import workflows from another system | `pikku-n8n-import` |\n\n**Running**\n\n| Command | What it does | Read |\n| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------- |\n| `dev` | Local dev server, all services wired, watch + HMR | `pikku-build` |\n| `serve` | Bundled bun/node runner — no watch, no codegen | `pikku-deploy` |\n| `watch` | Regenerate on file change, without a server | — |\n| `scenario list\\|run` | Scenarios as e2e tests and health checks | `pikku-scenario` |\n| `persona run` | A declared persona as a model-driven virtual user against a stage | `pikku-scenario`, persona-run reference |\n| `persona list\\|sync\\|secret` | Who is declared; what an environment will provision; minting their credentials | `pikku-scenario`, persona-run reference |\n| `db` | Local development database | `pikku-kysely` |\n\n**Inspecting and evolving**\n\n| Command | What it does | Read |\n| --------------------- | ----------------------------------------------------------------------- | ---------------------------- |\n| `doc` | The installed API surface | this skill |\n| `meta` / `info` | What the project declares, machine- and human-readable | `pikku-meta` |\n| `validate` | Every check that applies — app structure, an addon's published file set | `pikku-build`, `pikku-addon` |\n| `versions` / `semver` | Contract hashes, breaking-change detection, the release semver | `pikku-meta` |\n| `audit` / `update` | Advisories; which `@pikku/*` can move and what peers that needs | `pikku-meta` |\n| `scopes` / `roles` | Declared authorization scopes; roles from `defineSystemRole` | `pikku-auth` |\n| `knowledge` | The knowledge base — what this app is, in its users' language | `pikku-knowledge` |\n| `emails` | Email template generation | `pikku-emails` |\n\n**Shipping, and the CLI itself**\n\n| Command | What it does | Read |\n| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |\n| `deploy` | Deploy to cloud infrastructure | `pikku-deploy` |\n| `fabric` | PikkuFabric: login, link, deploy, domains, secrets, logs | `pikku-fabric` |\n| `binary` | Compile an entrypoint to a native binary (`bun build --compile`) | — |\n| `dist` | Copy what `tsc` cannot emit — `.gen.json` meta, hand-authored `.d.ts` — into the build output. Run it after `tsc`, as a package's build script | — |\n| `login` / `logout` / `whoami` | The CLI's session against a pikku server | — |\n| `skills` | Install these skills into an agent (Claude Code, opencode, pi) | — |\n\n`-c/--config`, `--log-level`, `--json` and the filter flags are **global\noptions**, not commands — they attach to the generating commands above.\n\nA dash means no skill covers it beyond this line. `--help` is then the whole of\nit — which is a reason to read `--help` rather than to assume the command does\nwhat its name suggests.\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 ├── pikkuAgent → 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 and authorization checks → `pikku-auth`, a separate install; 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 — prose, so it follows `metaLocale` in\n // pikku.config.json (default `en`). The identifier does not; see\n // \"What Language You Write In\".\n title?: string, // Human-readable name\n description?: string, // What the function does\n version?: number, // Contract version (see pikku-meta)\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-wiring)\n remote?: boolean, // Allow remote RPC calls\n mcp?: boolean, // Expose as MCP tool (see pikku-wiring)\n readonly?: boolean, // Declares the function performs no writes\n deploy?: 'serverless' | 'server' | 'auto',\n\n // Authorization — see pikku-auth\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-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**Only `pikku dev` and `pikku serve` invoke these hooks.** No deploy runtime does, so anything a Workers or serverless stage needs done cannot live here — put it on the request path that needs it, guarded by a cheap check.\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- one directory per wiring (`function/`, `http/`, `workflow/`, …), each with an\n `index.ts` reached as `#pikku/<name>` — typed function factories and wiring\n functions, split so an app pulls in only the wirings it uses\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-middleware)\n├── permissions.ts # Permission definitions (see pikku-auth)\n└── .pikku/ # Generated (gitignored)\n ├── function/ # #pikku/function\n ├── http/ # #pikku/http\n ├── pikku-fetch.gen.ts\n └── pikku-bootstrap.gen.ts\n```\n\n## What Language You Write In\n\nThree different things in a Pikku project have a human language, and they are\n**not** the same language. Collapsing them is the mistake this section exists to\nprevent, and it has already shipped in a real product — the failure is at the\nbottom.\n\n| Axis | What it covers | What decides it |\n| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Branch names and commit messages. | Nothing. **Always English.** There is no setting. |\n| **Meta** | The prose authored _inside_ the code: `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role/persona descriptions. | `metaLocale` in `pikku.config.json`. Defaults to `en`. |\n| **Product UI** | Every string the app shows a user. | `messages/<locale>.json`, with `active.json`'s `defaultLocale` choosing what a first-time visitor opens in. |\n\n### Identifiers are English, and nothing changes that\n\nNot the product's market, not the team's working language, and **not `metaLocale`**.\nA German medical practice, an Arabic marketplace and a Japanese logistics tool\nall get `getOverview`, `AttentionStripe`, `case`, `event`.\n\nThis is not linguistic preference, it is mechanics. Identifiers are the surface\nevery other tool binds to: the generated `#pikku/*` clients, `pikku info` and\n`pikku meta`, the RPC map a scenario's `actor.invoke` is typed over, the\ngenerated SQL types, every skill and every agent that ever picks the project up.\nA `vorgang` table types as `Vorgang` in Kysely and reads as noise to everyone who\ndid not name it, and unlike a string it cannot be translated later — renaming an\nidentifier is a migration, not an edit.\n\n### Meta follows `metaLocale`, and that is what the field is for\n\n```json\n{ \"metaLocale\": \"de\" }\n```\n\nMeta is the one part of a project the **Pikku Console** renders back to a human.\nA team reviewing their own functions, features and scenario reports in the\nConsole is reading meta and nothing else, so a team whose working language is\nGerman should be able to read their Console in German. That is the entire reason\nthe field exists.\n\nRead it before you author meta, and write descriptions, titles and templates in\nit. Absent, it is `en`. It is a BCP-47 tag (`en`, `de`, `pt-BR` — a hyphen, not\nan underscore), and the CLI rejects anything else by name.\n\n`metaLocale` is **not** licence to rename anything. `metaLocale: \"de\"` buys a German\n`description: 'Zeigt die Arbeitsliste'` on a function still called\n`getWorklist`.\n\n### Product UI language lives in the catalogue, and only there\n\nWhat the app says to its users is a translation concern, not a code concern. It\nbelongs in `messages/<locale>.json`; `pikku-i18n` owns the details. The one rule\nworth repeating here: **`baseLocale` in `project.inlang/settings.json` stays\n`en`.** It names the message _source_ — the catalogue every other language is\ncloned from and translated against — so a project that sets it to anything else\nhas no English catalogue to translate from and can never gain a second language\nwithout re-authoring every key.\n\n### The failure this comes from\n\nAn agent was asked to build a doctor's portal for a German practice. The brief\nsaid \"the entire UI is German, no English strings visible anywhere\". The agent\nread one sentence about the product's users as an instruction about the\ncodebase, and produced:\n\n- `project.inlang/settings.json` with `baseLocale: \"de\"` and `locales: [\"de\"]`,\n no `en.json` at all — which silently broke `--add-locale` forever\n- RPC functions `getUebersicht` and `getPatientendetail`\n- React components `Zeitstrahl` and `AufmerksamkeitStreifen`\n- database tables `vorgang` and `ereignis`, with German columns\n\nEvery one of those is wrong, and the brief was satisfied by none of them: a\nGerman UI needs German _messages_. What that project actually wanted was three\nsettings, each on its own axis:\n\n```jsonc\n// project.inlang/settings.json — the message source stays English\n{ \"baseLocale\": \"en\", \"locales\": [\"en\", \"de\"] }\n\n// apps/app/src/i18n/active.json — what a first-time visitor opens in\n{ \"defaultLocale\": \"de\" }\n\n// pikku.config.json — the language the team reads their Console in\n{ \"metaLocale\": \"de\" }\n```\n\nIdentifiers stay English throughout. When a brief tells you the product speaks a\nlanguage, it is telling you about axis three and nothing else.\n\n## Environment Variables\n\nNever use `process.env` inside Pikku functions. Use the `variables` service (see `pikku-services`):\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-deploy/references/azure.md": "# Azure Functions\n\n```bash\nyarn add @pikku/azure-functions @azure/functions\n```\n\n`createAzureHandler(factories, handlerTypes)` is the entry point, returning\n`{ http?, queue?, timer? }` for the handler types you ask for.\n`createAzureWorkerHandler(factories)` is `createAzureHandler(factories,\n['fetch'])`. `factories` is `{ createConfig, createSingletonServices,\ncreatePlatformServices? }`; services are built from `process.env` and cached in\nmodule scope across invocations of the same instance.\n\n**Channels do not work on Azure.** `createAzureWebSocketHandler` is a stub whose\n`negotiate` always answers `501 WebSocket via Azure Web PubSub not yet\nimplemented` — do not plan a deployment around it.\n\nTwo naming traps: the logger is `AzInvocationLogger`, not\n`PikkuAzFunctionsLogger`; and `PikkuAZTimerRequest(context, data)` accepts the\ncontext argument and ignores it.\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({ createConfig, createSingletonServices }, [\n 'fetch',\n 'queue',\n '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/references/cloudflare.md": "# Cloudflare Workers\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 — do not hand-roll this\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\nBeyond building `LocalVariablesService` / `LocalSecretService` and caching the\nresult, it calls `setSingletonServices()` — and the core runners (`fetchData`,\n`runQueueJob`, `runScheduled`) resolve services through that global slot, _not_\nthrough the value you were returned. A setup function that only returns the\nservices leaves every request throwing \"Singleton services not initialized\" as a\nCF `1101`. It also stashes the env via `setCloudflareEnv`, which\n`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 `controller.cron`\nand **returns after the first match**. Two tasks sharing one cron expression\nmeans only one of them ever runs — give each its own expression, or invoke\n`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/references/express.md": "# Express\n\n```bash\nyarn add @pikku/express\n```\n\n## Standalone server\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\nThe config extends `CoreConfig` with `port`, `hostname`, an optional\n`healthCheckPath`, an optional `limits` map and an optional `content`\n(`LocalContentConfig`) for static assets and file uploads. `app: Express` is\nexposed for custom middleware, and `getHttpServer()` returns the underlying\n`http.Server` — to attach a WebSocket server, say — but throws before `start()`.\n\n`enableStaticAssets()` serves `content.localFileUploadPath` under\n`content.assetUrlPrefix`, and `enableReaper()` adds a `PUT /reaper/*path` upload\nsink for local development, path-traversal checked and bounded by\n`content.sizeLimit` (default `1mb`). Both throw when `content` is unset.\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`stop()` throws if the server was never started. `enableExitOnSigInt()`\ninstalls a SIGINT handler that stops the singleton services, then the server,\nthen exits 0.\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 `req.body`,\nso a body parser and `cookie-parser` must be registered before it, and\n`maxBodySize` alone will not stop an oversized request that your parser already\naccepted. Unmatched requests fall through to `next()` (unless `respondWith404`\nis set), so Pikku's routes coexist with your existing ones; a streaming response\nis the exception and does not call `next()`.\n", "pikku-deploy/references/fastify.md": "# Fastify\n\n```bash\nyarn add @pikku/fastify\n```\n\n## Standalone server\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\nThe config extends `CoreConfig` with `port`, `hostname` and an optional\n`healthCheckPath`. `app: FastifyInstance` is exposed for direct access.\n\n`enableCors` exists on the class but **throws `Method not implemented.`** —\nunlike the Express server. Register `@fastify/cors` on `app` yourself before\n`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/references/lambda.md": "# AWS Lambda\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 store.\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/references/nextjs.md": "# Next.js\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`get`, `post`, `patch`, `del` and `rpc` read `next/headers` cookies and headers,\nso they force the component dynamic. `staticGet`, `staticPost` and `staticRPC`\nhave no request context and are safe for 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\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 response\n 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/references/uws.md": "# uWebSockets.js\n\nHighest-throughput option among Pikku's runtimes. Handles both HTTP and\nWebSocket 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\nThe config extends `CoreConfig` with `port`, `hostname` and an optional\n`healthCheckPath`. `app: uWS.App` is exposed for direct access.\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\nFor a WebSocket-only server on the `ws` library instead, see `ws.md`.\n", "pikku-deploy/references/ws.md": "# ws (WebSocket only)\n\n`@pikku/ws` connects Pikku's channel system to a Node.js WebSocket server built\non the [ws](https://github.com/websockets/ws) library. Use it for a\nWebSocket-only server; for HTTP and WebSocket on one port see `uws.md`, and when\nthe WebSocket server shares a port with an existing HTTP app see `express.md` or\n`fastify.md`.\n\n```bash\nyarn add @pikku/ws ws\n```\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 { DEFAULT_WS_MAX_PAYLOAD, 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({\n noServer: true,\n maxPayload: DEFAULT_WS_MAX_PAYLOAD,\n})\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\nprocess.on('SIGINT', async () => {\n await stopSingletonServices()\n wss.close()\n server.close()\n process.exit(0)\n})\n```\n\n## `noServer: true` is required, not stylistic\n\nThe handler listens for the HTTP server's own `upgrade` event, opens the channel\n— running Pikku's middleware chain, auth and CORS against the upgrade request\nfirst — and only then calls `wss.handleUpgrade`. A `WebSocketServer` bound to the\nserver directly would take the socket before any of that ran.\n\nAn upgrade the channel rejects gets the socket destroyed, and an auth failure is\nwritten as a real HTTP response on the raw socket rather than a silent drop.\n\n## Services and the event hub\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.\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", "pikku-deploy/SKILL.md": "---\nname: pikku-deploy\ndescription: >-\n Use when deploying a Pikku app to a runtime — Express, Fastify, uWebSockets.js, the `ws` library,\n Next.js, AWS Lambda, Cloudflare Workers or Azure Functions. Covers the bootstrap every runtime\n shares, choosing between them, and the behaviour that differs: which accept\n `RunHTTPWiringOptions`, how each one runs scheduled tasks, and where CORS and health checks live.\n TRIGGER when: writing or debugging `start.ts` / a worker entry / a Lambda handler, code imports\n `@pikku/express`, `@pikku/fastify`, `@pikku/uws`, `@pikku/ws`, `@pikku/next`, `@pikku/lambda`,\n `@pikku/cloudflare` or `@pikku/azure-functions`, or the user asks how to serve, host or deploy a\n Pikku app. DO NOT TRIGGER when: defining functions or wirings with no runtime-specific code.\ninstallGroups: [core]\n---\n\n# Pikku 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\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: which\nruntime to pick, and what each one does differently once you have.\n\n## Pick a runtime\n\nTwo families, and the difference decides how you write the entry file.\n\n**Long-running servers** own a process. Services are built once at startup and\nlive in module scope for the life of the server.\n\n| Runtime | Package | Reach for it when |\n| --- | --- | --- |\n| Express | `@pikku/express` | An existing Express app, or you want static assets and upload handling |\n| Fastify | `@pikku/fastify` | An existing Fastify app, or you want its stricter defaults |\n| uWebSockets.js | `@pikku/uws` | Highest throughput, HTTP and WebSocket on one port |\n| ws | `@pikku/ws` | WebSocket only, attached to an `http.Server` you own |\n\n**Per-invocation runtimes** are handed a request and torn down. Services are\ncached in module scope across warm invocations, and the deploy codegen writes\nthat caching for you.\n\n| Runtime | Package | Reach for it when |\n| --- | --- | --- |\n| AWS Lambda | `@pikku/lambda` | API Gateway, EventBridge, SQS |\n| Cloudflare Workers | `@pikku/cloudflare` | Edge, Durable Objects for channels |\n| Azure Functions | `@pikku/azure-functions` | Azure hosting — note channels are not implemented |\n| Next.js | `@pikku/next` | Pikku behind Next routes, or RPC from Server Components |\n\nThen read the reference for the one you picked: `references/express.md`,\n`references/fastify.md`, `references/uws.md`, `references/ws.md`,\n`references/nextjs.md`, `references/lambda.md`, `references/cloudflare.md`,\n`references/azure.md`.\n\n## The bootstrap every runtime shares\n\nImporting the generated bootstrap registers your wirings; nothing routes without\nit. `createConfig` and `createSingletonServices` come from your own\n`services.ts`.\n\n```typescript\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n```\n\nA long-running server takes it from there. A per-invocation runtime wraps the\nsame two calls in a memoised factory, because the module may be reused across\ninvocations — every `@pikku/*` serverless package ships those factories, and\nhand-written handlers are for the cases the deploy codegen does not cover.\n\n## What differs, and where it bites\n\n### `RunHTTPWiringOptions` is not accepted everywhere\n\n`maxBodySize`, `respondWith404`, `coerceDataFromSchema` and `bubbleErrors` reach\nthe request only on the runtimes that thread them through.\n\n| Runtime | Accepts options | Where an oversized body is actually stopped |\n| --- | --- | --- |\n| Express | `init(httpOptions)` | The `express.json` parser limit, fed by `maxBodySize` |\n| Fastify | `init(httpOptions)` | Fastify's own `bodyLimit`, set from `maxBodySize` |\n| uWS | HTTP handler only | The handler counts bytes itself and answers `413` |\n| ws | Yes, on the handler | `maxPayload` on the `WebSocketServer` |\n| Next.js | Only via your own `PikkuNextJS` handler | Next's own limits |\n| Lambda | **No** | API Gateway's payload limit |\n| Cloudflare | Partially — `runFetch(request, hibernation, options)` | The platform's limit |\n| Azure | **No** | Azure's request limits |\n\nOn Fastify, leaving `maxBodySize` unset keeps Fastify's stricter 1MB default\nrather than loosening it to Pikku's 10MB fallback. On uWS the chunks are dropped\nrather than concatenated, so an oversized request never accumulates in memory.\n\n### Scheduled tasks run differently on all three serverless runtimes\n\nSame `wireScheduler` declaration, three behaviours. This is the one most likely\nto produce a silent production bug.\n\n- **Cloudflare** matches `controller.cron` and **returns after the first match**.\n Two tasks sharing a cron expression means only one ever runs.\n- **Lambda** runs **every** task in the bundle, ignores the event, and logs\n rather than rethrows a failure — one bad task cannot stop the others.\n- **Azure** runs **every** task in the bundle, ignores both the timer argument\n and each task's own cron, and does **not** catch per-task failures — the first\n throw aborts the rest.\n\nWhere a runtime runs everything in the bundle, the deployment unit is what\ndecides which tasks fire, not the schedule you wrote. Reach for\n`runScheduledTask({ name })` when one deployment genuinely bundles several tasks\nthat must fire separately.\n\n### CORS and health checks are not uniform\n\n- **Express** registers the health check in the **constructor**, so it answers\n before any middleware and cannot be wrapped in auth. `enableCors` must be\n called before `init()`.\n- **Fastify** registers it in `init()`, so nothing answers before that runs. Its\n `enableCors` exists but **throws `Method not implemented.`** — register\n `@fastify/cors` yourself.\n- **uWS** registers it in `init()` and has **no** `enableCors`, no static assets\n and no `content` support at all.\n- Serverless runtimes have neither; the platform in front of them owns both.\n On Lambda, only `runFetchV2` echoes the request `Origin`, so v1 preflights\n fail in the browser unless API Gateway or CloudFront adds the header.\n\n### Channels need a shared store off a single process\n\nA long-running server can hold channel state in memory. Every per-invocation\nruntime cannot: `$connect` and `$default` are separate invocations, so\n`channelStore` must be a real shared store (`PgChannelStore` and friends).\nCloudflare instead keeps state in a Durable Object, and **Azure has no channel\nsupport** — `createAzureWebSocketHandler` is a stub that answers `501`.\n\n## What NOT to do\n\n- Do not hand-roll Cloudflare's `setupServices`. It calls\n `setSingletonServices()`, and the core runners resolve through that global\n slot rather than the value you were returned — a setup that only returns\n services leaves every request throwing \"Singleton services not initialized\" as\n a CF `1101`.\n- Do not discard what a Lambda WebSocket handler returns.\n `connectWebsocket` returns a complete `APIGatewayProxyResult`; answering a\n hardcoded `200` instead accepts every connection, including the ones your\n channel's auth rejected.\n- Do not bind a `WebSocketServer` to the HTTP server on `ws` or uWS.\n `noServer: true` is required, not stylistic — the handler performs the upgrade\n itself so middleware and auth run against the upgrade request first.\n- Do not assume a runtime rethrows. Express, Azure and Lambda's `runFetch` each\n swallow or flatten errors differently; the reference for your runtime says\n which.\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-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- `{{{verifyUrl}}}` — the same value **unescaped**. See below.\n\nLocale strings may themselves contain variables and partial-free placeholders, e.g.\n`\"subject\": \"{{inviterName}} invited you to join {{organizationName}}\"`. Locale files\nand `theme.json` ship alongside the templates, so they are expanded first and a subject\nof `{{t.invitation.subject}}` expands fully.\n\n## Escaping\n\nValues are HTML-escaped (`& < > \" '`) on the way into `.html` output, so a URL, a\ndisplay name or a font stack containing quotes lands inside its attribute instead of\nbreaking out of it. `.subject.txt` and `.text.txt` are plain text and are never escaped.\n\nRendering is **layered by trust**, and the layers do not leak into each other:\n\n- Partials are inlined first — a `data` value that happens to contain `{{> footer}}`\n is not an include.\n- `theme.*` and `t.*` are template-author input: expanded next, escaped, and allowed\n to contain further placeholders (up to 5 levels).\n- Everything else is caller data: substituted in **one pass**, escaped, and never\n rescanned — a `data` value containing `{{...}}` renders as those literal characters.\n\n`{{content}}` and partials are template-authored markup and stay raw. For a value you\ngenuinely want inserted as markup, use the explicit `{{{value}}}` form — it is opt-in,\nit bypasses escaping entirely, and it is only safe for HTML you control.\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 attachments: input.attachments,\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/references/debugging.md": "# 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-fabric/SKILL.md": "---\nname: pikku-fabric\ndescription: 'Build, convert and debug apps on the Pikku Fabric platform. Covers SQLite/libSQL database setup with Kysely, fabric project layout, deploy provider config, `fabric.config.json`, the pikku-verify workflow, and reading logs, traces and metrics from a deployed stage. TRIGGER when: user is working on a Fabric-hosted Pikku project, converting an app to Fabric format, asking about Fabric deployment, database or project conventions, asking about a `pikku fabric validate` finding including app-missing-actor-quick-login, or a deployed stage is erroring, timing out or behaving differently than local (\"why is prod failing\", \"check the logs\"). DO NOT TRIGGER when: user is working on a generic (non-Fabric) Pikku deployment — use pikku-deploy instead — or the failure reproduces locally, which is where to debug it.'\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-wiring`, `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 **local dev data only**: enough rows that a fresh dev database isn't an\nempty app. Nothing else ever runs it. A deployed stage applies `db/<engine>/*.sql`\nand stops there — reset refuses `NODE_ENV=production` and refuses a database\noutside the runtime directory, and no deploy step reaches for the seed file.\n\nSo the test is not \"is this row realistic?\", it is **\"would the app be broken\nwithout it in production?\"** If yes, it is configuration and belongs in a\nmigration, however much it looks like sample data. A venue and its rooms, a\nproduct catalogue, a tenant, a country list, the organization the whole\ndeployment hangs off — all configuration. Accounts and role grants are\nprovisioning: the fabric plugin's `personas`, or a migration. What is left\nover is the seed's job — the bookings, orders and messages a demo needs and a real\nenvironment starts without.\n\nGet this wrong and it hides: the app is perfect locally, where reset has just\nrun, and every deployed environment comes up with empty tables. The signature is\na stage whose pages return 200 — the shell renders fine — while its first data\nread throws `no result` or a foreign-key violation on a row the seed was\nsilently supplying.\n\nA Better Auth app has a second constraint: the plugins you enable (`pikkuBan()`,\n`pikkuActor()`, …) 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.\n\nThat pass is a smoke check. Anything you would otherwise verify by hand-driving a\nbrowser tool belongs in a scenario's browser step, run with\n`pikku scenario run local --spawn --run browser` — a browser session you steered\nyourself proves nothing that re-runs.\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 apply --production -y\n```\n\nThe branch is positional and defaults to the checked-out one, and `-y` is the\nshort form of `--auto-approve`, so a one-shot deploy is:\n\n```bash\npikku fabric deploy apply -y # the branch you are standing on\npikku fabric deploy apply my-branch -y # a named one\n```\n\n`-y` answers the prompts and nothing more. It does **not** approve migrations\nthat drop or rewrite data — that stays `--allow-destructive`, typed out on\npurpose.\n\nInferring the branch is safe because the git safety check refuses any branch\nwithout an upstream or out of sync with it, so it cannot ship an unpushed\ncommit; the branch it picked is printed before the build starts. A detached\nHEAD is refused by name rather than travelling on as a branch called `HEAD`.\n\nThere is no `deploy plan` subcommand — `apply` runs the same auth, git-safety\nand ref resolution itself, and fabric produces the real plan server-side.\n\n`apply` confirms before deploying, and with no TTY to ask — CI, an agent shell —\nit refuses rather than hangs. `--auto-approve` (`-y`) supplies that confirmation;\ndrop it only when a human is at a real terminal.\n\n`apply` waits for a terminal state and exits non-zero unless the deployment went\nlive. `--detach` opts out — it queues the deploy, prints the deployment id and\nreturns 0, which tells you nothing about whether it worked:\n\n| exit | meaning |\n| ---- | ----------------------------------------------------------------------- |\n| 0 | live (or queued, under `--detach`) |\n| 1 | the command could not run — not logged in, unsafe git state, bad flags |\n| 2 | the deployment failed, errored, timed out server-side, or was cancelled |\n| 3 | the deployment is blocked and nothing the CLI can do will unblock it |\n| 4 | the wait hit `--timeout` with the deployment still in flight |\n\nFabric parks every deploy at a gate after the plan phase (`status: suspended`).\nWhy it parked is the whole story, and it is `statusReason`, not `status`:\n\n- `awaiting_approval` — the plan is fine, a human has to publish it.\n `-y` does that; without it you get exit 3 and the command to run.\n One exception: if fabric marked any pending migration **destructive** — a\n drop, a truncate, a rewrite — `-y` alone declines and exits 3,\n because a standing yes was given before anyone knew the plan dropped a table.\n The CLI lists the migrations and fabric's reasons; `--allow-destructive`\n accepts them for that deploy, and `-y` implies it.\n- `needs_config` — a declared secret or variable has no value covering the\n stage. The CLI names them. `-y` will **not** force this through;\n set the values and re-attach — `pikku fabric secrets set <name>` for a\n declared secret, `pikku fabric variables set <name> --value <v>` for a declared\n variable. They are separate stores: a secret is sealed to the stage and cannot\n be read back, a variable is stored plainly and can (`variables get`). `set`\n reads the value as JSON when it parses, so `--value true` is the boolean on a\n stage exactly as it is from `.env`, and `--value '\"true\"'` is the string.\n- `needs_attention` — the plan is red. Nothing to approve.\n\nThe wait defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout\nit prints the deployment id and the re-attach command rather than lying about\nthe outcome.\n\nSplitting kick-off from waiting across two CI jobs is the reason\n`--deployment-id` exists, and what `--detach` is for — the first job here has to\nreturn the id and exit rather than wait:\n\n```bash\nid=$(pikku fabric deploy apply --production -y --detach --json | jq -r 'select(.event==\"result\").deploymentId')\n# …later, in another job…\npikku fabric deploy apply --deployment-id \"$id\" -y\n```\n\n`--deployment-id` skips the git safety check entirely (the deployment already\npins a sha, and the checkout is allowed to have moved on) and refuses to be\ncombined with a branch or `--production`, which would let the two disagree.\n\nUnder `--json`, the wait emits one NDJSON event per line — `created`/`attached`,\n`status` on each transition, `blocked`, `approved` — and the last line is the\nterminal result object, tagged `\"event\": \"result\"`.\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### `app-missing-actor-quick-login-<app>`\n\nThe `fabric validate` finding people most often misread. It fires when an app has\na **login screen** but no dev actor switcher, and it is not a style nit: a sandbox\nreviewer has no seed password, so without the control they are locked out of the\napp they were asked to look at.\n\nSatisfy it with `<DevActorSwitcher />` from `@pikku/mantine/dev`, or with your\nown UI built on `useDevActors()` from `@pikku/react` — validate accepts either\ncall site as evidence, so custom rendering passes. See **pikku-react** for the\nprops and **pikku-scenario** for where the actor list comes from.\n\nThe validator also accepts the shapes that predate the package — a hand-rolled\n`signInAsActor()` or a literal `POST /auth/sign-in/actor` — so an older app does\nnot fail the build. **Treat that as a grace period, not the target: migrate those\nto `<DevActorSwitcher />`.** The hand-copied version is exactly the duplication\nthe package exists to remove, and the copies drift — the ones that prompted this\nhad already diverged on the `import.meta.env.DEV` gate that keeps the shared\nsecret out of production bundles.\n\nDo **not** satisfy it with Better Auth's `/dev/quick-login`. That is a different\nendpoint with a different purpose — one fixed admin, not the declared personas —\nand it does not clear this rule.\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/error`.\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- **Identifiers are English** — functions, components, types, files, database tables and columns, in every app whatever market it serves. The team's language is `metaLocale` in `pikku.config.json` and reaches `description`/`title`/`template` only; the app's language is the message catalogue, where `baseLocale` stays `en` and `defaultLocale` decides what a visitor opens in. `pikku fabric validate` warns (`app-base-locale-not-english-<app>`) when an app repoints its base.\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` — plus `metaLocale` if the team does not work in English, which is the language every `description`, `title` and step `template` is then authored in.\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\n## A deployed stage misbehaving\n\nReproduce locally first — a deployed stage adds cost and latency to every\niteration, and a failure that reproduces locally is a local debugging problem.\nWhen it only happens deployed, read `references/debugging.md`: start from\n`errors` rather than `logs` (they are already filtered and carry the traceId),\nfollow one trace end to end before forming a theory, and confirm the fix against\nthe same stage. A deploy that *failed* is a build or config problem and belongs\nabove, not there.\n", "pikku-i18n/references/enum-labels.md": "# 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 =\n '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\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({\n project: './project.inlang',\n outdir: './src/paraglide',\n }),\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-i18n/references/messages.md": "# 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. **`baseLocale` stays `en` whatever language the product speaks** — see [The product's language is not the code's language](#the-products-language-is-not-the-codes-language), which is the first thing to read if the brief says the app is not in English.\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 product's language is not the code's language\n\nA brief that says \"the entire UI is German, no English strings visible anywhere\"\nis a statement about **one** of three separate things, and reading it as a\nstatement about the codebase is the single most expensive mistake available in\nthis skill. Three axes:\n\n| Axis | What it covers | What sets it |\n| --------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |\n| **Identifiers** | Function, component, type and file names; database tables and columns | Nothing — always English, no setting |\n| **Meta** | `description` / `name` / `title` / `template` authored inside the code, which the Pikku Console renders | `metaLocale` in `pikku.config.json`, default `en` |\n| **Product UI** | Every string the app shows a user | `messages/<locale>.json` + `defaultLocale` — **this axis only** |\n\nA non-English product moves the third row and nothing else.\n\n### `baseLocale` stays `en`\n\n`baseLocale` in `project.inlang/settings.json` does not mean \"the language the\napp is in\". It names the message **source** — the catalogue every other locale is\ncloned from and translated against. Setting it to the product's language looks\nlike it works, because the app does come up in that language, and then:\n\n- there is no `en.json`, so `--add-locale` has no catalogue to translate from\n- the app can never gain a second language without re-authoring every key\n- a message missing from a locale falls back to a catalogue nobody wrote\n\nThe setting that actually decides what a first-time visitor sees is\n`defaultLocale`, held in `apps/app/src/i18n/active.json` in the Fabric app\ntemplate and read by `src/i18n/config.ts`. It is deliberately a separate file\nfrom `settings.json` for exactly this reason — the source language and the\nserved language are different questions.\n\nSo a German medical portal is **three** settings, not one:\n\n```jsonc\n// project.inlang/settings.json — the source catalogue is English\n{ \"baseLocale\": \"en\", \"locales\": [\"en\", \"de\"] }\n\n// apps/app/src/i18n/active.json — what a visitor opens in\n{ \"defaultLocale\": \"de\" }\n\n// pikku.config.json — the language the team reads their Console in\n{ \"metaLocale\": \"de\" }\n```\n\nIn the Fabric template both of the first two have a command, so you rarely edit\nthem by hand:\n\n```sh\nfabric i18n --add-locale de # adds \"de\" to locales, seeds messages/de.json from en.json\nfabric i18n --default-locale de # writes active.json — the app now OPENS in German\n```\n\n### The failure this is written from\n\nA real build, from this template. The brief said the UI was German; the agent\nset `baseLocale: \"de\"` with `locales: [\"de\"]` and no `en.json`, then carried the\nsame reading into the code — RPC functions `getUebersicht` and\n`getPatientendetail`, components `Zeitstrahl` and `AufmerksamkeitStreifen`,\nhelpers `datumDeutsch` and `voraussichtlichFertig`, database tables `vorgang`\nand `ereignis` with German columns.\n\nThe German UI it was asked for needed none of that. It needed German **values**\nin a catalogue whose keys and source stayed English. What it got instead was a\nproject that cannot add a second language and cannot be picked up by anyone who\ndoes not read German.\n\nIf you find a project in this state, say so plainly rather than working around\nit: `baseLocale` cannot be repointed without re-keying every message, so it is a\nmigration someone has to agree to, not a fix to slip in.\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 `references/enum-labels.md` 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`. **Leave `baseLocale` at `en`** — step 1 only works because there is an English catalogue to mirror.\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`.\n5. Only if the app should **open** in the new language rather than merely offer it: set `defaultLocale` (`active.json` / `fabric i18n --default-locale fr`). Adding a locale and changing the default are different asks — do the second only when asked.\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 set `baseLocale` to anything but `en`, whatever language the product speaks. It names the source catalogue, and a project without one can never add a language. Set `defaultLocale` instead.\n- Don't let a non-English UI reach the identifiers. Functions, components, types, files, tables and columns are English in every project; the product's language lives in `messages/*.json` and nowhere else.\n- Don't translate message **keys**. `auth__login__title` stays English in `de.json`; only the value changes.\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\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-i18n/references/rtl.md": "# Pikku RTL (Arabic + English)\n\nThis reference sits **on top of** `references/messages.md`. That one 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 `references/messages.md`. 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 `references/messages.md`. There is no `t()` and no i18next in a\n Pikku frontend — the string comes from `m.some__key()`.\n", "pikku-i18n/SKILL.md": "---\nname: pikku-i18n\ndescription: >-\n Use when writing user-facing text in a Pikku frontend, or making one speak another language.\n Covers Paraglide JS message functions compiled from messages/<locale>.json, adding a second\n language, generated enum-label maps with @pikku/paraglide, and right-to-left support for Arabic,\n Hebrew, Farsi and Urdu. TRIGGER when: scaffolding or editing a frontend and writing display\n text, asked to make copy translatable, adding a language, labelling an enum/status/role value,\n or asked to support RTL / mirror the layout. DO NOT TRIGGER for backend functions, error\n messages thrown from functions, or log output — none of those are display strings.\ninstallGroups: [client]\n---\n\n# Pikku i18n\n\n## Every visible string is a message\n\nNever hardcode display text: add a key to `messages/en.json` and render\n`m.the__key()`. This holds even in an app that will only ever ship English —\nthe messages are the seam a second language slots into, and the deploy pipeline\ntype-checks them, so an i18n mistake blocks the build rather than the release.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Writing copy, wiring Paraglide, or adding a language | `references/messages.md` |\n| Labelling an enum, status, kind or role value | `references/enum-labels.md` |\n| Adding Arabic (or Hebrew, Farsi, Urdu), or writing layout styles | `references/rtl.md` |\n\n## Three axes, and a brief usually means only one\n\n\"The entire UI is German\" is a statement about the product, not about the\ncodebase. Reading it as one about the codebase is the most expensive mistake\navailable here.\n\n| Axis | What it covers | What sets it |\n| --- | --- | --- |\n| **Identifiers** | Function, component, type and file names; tables and columns | Nothing — always English |\n| **Meta** | `description` / `name` / `title` authored in code, rendered by the console | `metaLocale` in `pikku.config.json` |\n| **Product UI** | Every string the app shows a user | `messages/<locale>.json` + `defaultLocale` |\n\n`baseLocale` stays `en` whatever language the product speaks — it names the\nmessage *source* catalogue every other locale is derived from, not the language\nthe app is in. Set `defaultLocale` instead.\n\n## Direction is one setting, not per-component work\n\nSet `dir` once at the document root from the active locale and the browser (and\nMantine) mirror everything — provided every custom style is flow-relative\n(`margin-inline-start`, `text-align: start`, Mantine `ms`/`me`) rather than\nphysical (`margin-left`, `text-align: left`, `ml`/`me`'s physical twins). Write\nlogical properties from the start even in an English-only app; that discipline\nis what makes an RTL language just another locale file.\n\n## What NOT to do\n\n- **Do not resolve a message key at runtime.** No `mKey('status.' + value)`, no\n `m['enum__' + x]()`, no key-string resolver. A computed key cannot be\n type-checked or tree-shaken, so a renamed message degrades to silent runtime\n text. Where the key is genuinely dynamic, map the discriminant to a message\n *function* — the map is checked, a string is not.\n- **Do not `asI18n()` a hardcoded English string.** `asI18n` exists to pass\n opaque server data (a name, a slug, an id) through the i18n gate. An enum value\n goes through its generated label map.\n- **Do not wrap `m` without a reason you can name.** `m.some__key()` already\n satisfies the `I18nNode` gate, so a plain re-export module adds nothing and\n costs per-message tree-shaking. Wrapping the namespace is only worth it when it\n buys a feature the gate cannot — debug masking of translated copy, say — and\n then the catalogue has to be small enough to ship whole.\n- **Do not translate message keys.** `auth__login__title` stays English in\n `de.json`; only the value changes.\n- **Do not edit or commit `src/paraglide/`, `i18n-enum.gen.ts` or `enums.gen.ts`.**\n Change the catalogue or the migration and regenerate.\n- **Do not fake RTL** with `flex-direction: row-reverse`, reversed DOM order, or\n per-locale layout branches. They double-flip the moment direction changes. DOM\n order is logical order; let `dir` decide the visual one.\n- **Do not reach for i18next or a runtime translation loader.** Paraglide's\n compiled functions are the whole delivery mechanism.\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` — or `milestone`, when the project's own `knowledge/index.md` names the section that way; `validate` accepts both, so follow the scaffold rather than this list. Then `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### The milestone plan\n\nA milestone note says what the app must DO. Its **plan** — JSON beside the note, not prose — says what has to exist for it, and is what a finished build is measured against:\n\n```bash\npikku knowledge plan schema # the format, in full\npikku knowledge plan set <milestone> <file> # validate and write it\npikku knowledge plan show <milestone> --for-build # the ordered work a build follows\npikku knowledge plan progress <milestone> # what it still owes, read from .pikku/\npikku knowledge plan defer <milestone> <item> -r \"<why>\"\n```\n\n`progress` reconciles the plan against pikku's generated meta — set membership, never anyone's status — and exits non-zero while the first pass is short, or while anything already built contradicts the plan. Unbuilt work in a later pass is reported, not blocked; a function that shipped wide open against a planned permission rule blocks from any pass, because that is a hole rather than a backlog. Writing a plan is its own seat: read `pikku-architect`. Building against one is `pikku-build`.\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 or Redis-backed\n services (use pikku-service-backends).\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\n .selectFrom('item')\n .select(['id', 'name', 'quantity'])\n .where('warehouseId', '=', warehouseId)\n .orderBy('name')\n .limit(50)\n .execute()\n\n// JOINS + aliased selects (qualify columns once a join exists)\nawait kysely\n .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\n .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\n .insertInto('item')\n .values({ name: input.name, warehouseId })\n .returning(['id', 'name'])\n .executeTakeFirstOrThrow()\n\n// UPDATE + RETURNING, DELETE\nawait kysely\n .updateTable('item')\n .set({ quantity: input.quantity })\n .where('id', '=', input.id)\n .returning(['id', 'quantity'])\n .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\n .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\n .selectFrom('warehouse')\n .select((eb) => [\n 'warehouse.id',\n 'warehouse.name',\n jsonArrayFrom(\n eb\n .selectFrom('bin')\n .select(['bin.id', 'bin.code'])\n .whereRef('bin.warehouseId', '=', 'warehouse.id')\n ).as('bins'),\n ])\n .execute()\n\n// TRANSACTION — multi-write atomicity. Use trx (not kysely) inside.\nawait kysely.transaction().execute(async (trx) => {\n await trx\n .updateTable('stock')\n .set({ quantity: 0 })\n .where('itemId', '=', id)\n .execute()\n await trx\n .insertInto('stockMove')\n .values({ itemId: id, delta: -qty })\n .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| `*AgentStorageService` | `AgentStorageService, AgentRunStateService` | 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` | Outgoing webhook deliveries + attempt history (see `pikku-webhook`) |\n| `KyselyCredentialService` | Encrypted third-party credentials |\n| `KyselyAgentRunStateService` | AI run state (also implemented by AIStorage) |\n| `KyselyWorkflowMirror` | Mirrors workflow runs into queryable tables |\n| `KyselyAuditService` | Durable audit sink (see `pikku-services`) |\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-services` 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-list-query/SKILL.md": "---\nname: pikku-list-query\ndescription: >-\n Use when building a paginated/infinite-scroll list — any RPC that returns rows a user scrolls through (tables, card grids, search results). Covers pikkuListFunc, the ListInput/ListOutput cursor contract, and the generated usePikkuInfiniteQuery hook.\n TRIGGER when: user asks for infinite scroll, \"load more\", a paginated table/list/grid, or a list that could grow beyond a single page.\n DO NOT TRIGGER when: the list is small and fixed (e.g. a settings page with 5 items) — a plain pikkuFunc + usePikkuQuery returning a full array is simpler and correct there.\ninstallGroups: [core, client]\n---\n\n# Pikku List Queries\n\n## Agent Operating Procedure\n\n1. Capture baseline. Run `pikku all` BEFORE writing code; only NEW errors are yours to fix.\n2. Write the backend function with `pikkuListFunc` (below) — never a bespoke `{items: [...]}` shape once the list can page.\n3. Run `pikku all` to regenerate `usePikkuInfiniteQuery` for the new function.\n4. Wire the frontend with `usePikkuInfiniteQuery`, not a hand-rolled `useState` page counter and not a raw `useInfiniteQuery` — the generated hook already resolves cursor plumbing from your function's types.\n5. Validate with `pikku all`.\n\n## The `pikkuListFunc` contract\n\nA list function is a normal `pikkuFunc`/`pikkuSessionlessFunc` whose input/output conform to two shared shapes from `@pikku/core`:\n\n```typescript\ninterface ListInput<F extends Record<string, unknown> = {}, S extends string = never> {\n cursor?: string // opaque — echo back whatever you returned as nextCursor\n limit?: number // page size; server may cap it\n sort?: Array<{ column: S; direction: 'asc' | 'desc' }>\n filter?: Filter<F> // structured AND/OR tree, Prisma-style leaf operators\n search?: string // free-text search across server-chosen fields\n}\n\ninterface ListOutput<Row> {\n rows: Row[]\n nextCursor: string | null // null = no more pages\n totalCount?: number // optional — skip when expensive to compute\n}\n```\n\nAdopting this shape is what makes the function eligible for the generated `usePikkuInfiniteQuery` hook — the react-query codegen structurally detects any RPC whose output includes `nextCursor` and generates an infinite-query hook for it automatically. No manual wiring, no opt-in flag.\n\n```typescript\nimport { pikkuListFunc } from '#pikku/function'\n\ninterface Item {\n id: string\n label: string\n}\n\nexport const listItems = pikkuListFunc<{ status?: string }, Item>({\n expose: true,\n auth: true,\n readonly: true,\n description: 'List items for the signed-in user, paginated.',\n // `input` is inferred as ListInput<{ status?: string }> from the generics above —\n // never re-annotate it inline.\n func: async ({ kysely }, input, { session }) => {\n // `limit` is caller-supplied on an exposed RPC, so it is CAPPED, not trusted —\n // `ListInput` says \"server may cap\" and this is where that happens.\n const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20) || 20, 1), 100)\n const parsed = input.cursor ? Number(input.cursor) : 0\n const offset = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0\n\n let query = kysely.selectFrom('item').where('userId', '=', session!.userId)\n const status = leafEquals(input.filter, 'status')\n if (status !== undefined) {\n query = query.where('status', '=', status)\n }\n\n const rows = await query.orderBy('createdAt', 'desc').offset(offset).limit(limit).execute()\n const nextOffset = offset + rows.length\n const totalCount = await query\n .select((eb) => eb.fn.countAll<number>().as('count'))\n .executeTakeFirstOrThrow()\n\n return {\n rows: rows.map((r) => ({ id: r.id, label: r.label })),\n nextCursor: nextOffset < totalCount.count ? String(nextOffset) : null,\n totalCount: totalCount.count,\n }\n },\n})\n```\n\nCursor doesn't have to be a numeric offset — any opaque string works (a keyset value, an encoded timestamp, etc.), as long as you can turn it back into a query position on the next call.\n\n## Frontend: `usePikkuInfiniteQuery`\n\nGenerated automatically alongside `usePikkuQuery`/`usePikkuMutation` once `reactQueryFile` is configured (see the react-query wiring docs) — no separate setup for list functions specifically.\n\n```tsx\nimport { usePikkuInfiniteQuery } from '.pikku/pikku-react-query.gen'\n\nfunction ItemList() {\n const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = usePikkuInfiniteQuery(\n 'listItems',\n { limit: 20 }, // never pass cursor here — the hook manages it\n )\n\n const rows = data?.pages.flatMap((page) => page.rows) ?? []\n\n return (\n <>\n {rows.map((row) => (\n <div key={row.id}>{row.label}</div>\n ))}\n {hasNextPage && (\n <button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>\n Load more\n </button>\n )}\n </>\n )\n}\n```\n\nFor scroll-triggered loading (rather than a button), pair it with an `IntersectionObserver` sentinel at the end of the list that calls `fetchNextPage()` when it enters the viewport and `hasNextPage` is true — don't poll on a scroll event handler.\n\n## Common mistakes\n\n- **Bespoke output shape** (`{items, total}` with no `nextCursor`) — compiles, but disqualifies the function from `usePikkuInfiniteQuery`; you're left hand-rolling pagination state. Use `ListOutput<Row>`'s field names (`rows`, `nextCursor`) even if you don't need `filter`/`sort`/`search` yet — they're optional.\n- **Fixed large `limit` instead of real pagination** (e.g. `{ limit: 500 }` fetched once) — works until the collection outgrows the cap, then silently truncates. If a list can grow unbounded, page it from the start.\n- **Passing `cursor` manually into `usePikkuInfiniteQuery`'s input argument** — the hook injects it into each page request itself; the input you pass is the _base_ filter/limit shared by every page.\n\n## `filter` is a TREE, not a bag of fields\n\n`Filter<F>` is recursive: an **array** is an AND of its children, a **multi-key object** is\nan OR keyed by labels that mean nothing at evaluation time, and only a **single-key object**\nis a leaf. A leaf's value is either the value itself or an operator object\n(`{ contains, in, gt, gte, lt, lte, not, startsWith, … }`).\n\nSo `'status' in input.filter` answers `false` for `[{ status: 'open' }, { userId: 'u1' }]`\nand for `{ status: { in: ['open', 'held'] } }` — the first because the filter is an array,\nthe second because the value is an operator object rather than the string the code then\ncompares. Both cases **silently return unfiltered rows**, which on a list endpoint means\nhanding back records the caller asked to exclude. Pikku ships no filter-to-SQL helper: the\nbackend decides what it accepts, and it has to say so.\n\nRead exactly the shape you support, and refuse the rest rather than ignoring it:\n\n```typescript\nimport type { Filter } from '@pikku/core/function'\n\n/** The one shape this endpoint accepts: a single-key leaf with a plain value. */\nfunction leafEquals<F extends Record<string, unknown>, K extends keyof F & string>(\n filter: Filter<F> | undefined,\n field: K,\n): F[K] | undefined {\n if (!filter || Array.isArray(filter)) return undefined\n const keys = Object.keys(filter)\n if (keys.length !== 1 || keys[0] !== field) return undefined\n const value = (filter as Record<string, unknown>)[field]\n if (value !== null && typeof value === 'object') {\n throw new Error(`filter.${field} takes a value, not an operator object`)\n }\n return value as F[K]\n}\n```\n\nSupporting AND/OR or operators means walking the tree properly — recurse into the array and\nthe multi-key object, and map each leaf operator to its Kysely equivalent. Do that when the\nUI needs it; until then, throwing on the shapes you do not handle is what stops a filter\nfrom being quietly dropped.\n", "pikku-meta/references/audit.md": "# 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## The `pikku update` command\n\nNarrower than `audit --outdated`, and the only one that writes: it moves the\n**@pikku/\\* set** forward and reports the peers those versions need. Use `audit`\nto learn a dependency is vulnerable; use `update` to move Pikku itself.\n\n- `pikku update` — reports only. Nothing is written without `--update`.\n- `pikku update --update` — writes the new ranges into every covered\n package.json, then runs an install. `--no-install` writes and stops.\n- `pikku update --update-peers` — implies `--update` and additionally writes the\n ranges unsatisfied peers require, **for peers the project already declares**.\n A peer it does not declare is reported and never added — adding a dependency\n is not an update. Separate from `--update` because a peer bump can cross a\n **major** of a third-party package (`ai` 5 → 6), which is not a call to make\n on the user's behalf.\n- `--tag <dist-tag>` (default `latest`) reads each package's own dist-tag, so\n `--tag next` moves the whole set onto prereleases. `--registry <url>` defaults\n to `npm_config_registry`.\n- Coverage is the nearest package.json walking up from the project root, plus\n every workspace it declares — a monorepo updates in one pass. All four\n dependency fields are read, `peerDependencies` included, so an addon's own\n declared peer range moves with it.\n\nStatuses, per dependency: `outdated` (the range floor is behind latest — this is\nwhat `--update` writes), `stale-install` (the range already admits latest but\nnode_modules is behind — an install fixes it, no edit needed), `linked` (a\n`workspace:`/`file:`/`link:`/`portal:` range — a deliberate local checkout,\ncounted but never listed), `manual` (a registry range we refuse to substitute\ninto: a union, an x-range, a `*`), `unresolved` (the registry had no such tag —\nthis must **never** read as \"current\", the same rule as a failed audit).\n\nPeers are read off the version the run **lands on**, not the one installed —\nthe point is what the target needs. An @pikku peer the same run already brings\nforward is not reported, and an unsatisfied _optional_ peer the project never\ndeclared is skipped.\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-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-meta/references/meta.md": "# Pikku Project Metadata\n\n`pikku meta` is the machine-readable view of the project and the write path to it.\n`pikku info` is the same ground as human-readable tables. Prefer `meta` when you are\ngoing to act on the output; prefer `info` when a person is going to read it.\n\n\n## Reading\n\n| Command | What it answers |\n| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| `pikku meta context` | Everything a planner needs in one call — functions, wires, middleware, permissions, workflows, capabilities, layout. Start here. |\n| `pikku meta functions get <id>` | One function's input/output schema names, source file, tags, expose/readonly |\n| `pikku meta schemas get <name>` | One generated JSON schema |\n| `pikku meta workflows get <id>` | One workflow's steps |\n| `pikku meta permissions list` | What permissions exist and where they are defined |\n| `pikku meta middleware list` | What middleware exists |\n| `pikku meta wires list` | Wires by transport (http, channel, scheduler, queue, trigger) |\n| `pikku meta clients` | Exposed RPCs/workflows/channels with their type names — what a frontend can call |\n\n`list` is the default for each group, so `pikku meta functions` and `pikku meta functions list`\nare the same call.\n\nA function's input/output shape comes from here. Do not infer it by reading the\nfunction body, and do not cast a call site to make it compile — the schema is the type.\n\n## Changing\n\n`pikku meta apply` applies a batch of edits to your own source. Pass JSON as a file\nor on stdin:\n\n```bash\npikku meta apply ops.json\n```\n\n```json\n{\n \"operations\": [\n {\n \"kind\": \"functionConfig\",\n \"sourceFile\": \"src/functions/todos.functions.ts\",\n \"exportedName\": \"listTodos\",\n \"changes\": { \"title\": \"List Todos\", \"tags\": [\"todos\", \"read\"] }\n },\n\n {\n \"kind\": \"functionConfig\",\n \"sourceFile\": \"src/functions/todos.functions.ts\",\n \"exportedName\": \"listTodos\",\n \"changes\": {\n \"permissions\": {\n \"functionLevel\": {\n \"name\": \"isTodoOwner\",\n \"from\": \"../permissions.js\"\n }\n }\n }\n }\n ]\n}\n```\n\nThree kinds: `functionConfig`, `agentConfig`, `functionBody`. Every operation names\na `sourceFile` and the `exportedName` declared in it.\n\n`functionConfig` changes: `title`, `description`, `summary`, `tags`, `errors`,\n`expose`, `remote`, `mcp`, `readonly`, `approvalRequired`, `permissions`.\n`agentConfig` changes: `name`, `description`, `instructions`, `role`, `personality`,\n`goal`, `model`, `maxSteps`, `temperature`, `toolChoice`, `tools`, `tags`.\n\n`null` removes a property. Edits are spliced into the original text, so formatting,\ncomments and JSDoc survive.\n\n`permissions` and `tools` are written as identifiers rather than literals, so each\none carries the module it comes from (`{\"name\": \"isTodoOwner\", \"from\": \"../permissions.js\"}`)\nand the missing import is added for you — widening an existing import from that\nmodule rather than adding a second one.\n\n### Why batch\n\nThe whole batch either lands or it does not: every operation is resolved before\nanything is written, so a failure leaves every file untouched and names the\noperation that caused it. Batching is also what makes one codegen pass correct —\n**run `pikku all` once after the batch**, not once per property. The response tells\nyou whether it is needed:\n\n```json\n{\n \"schemaVersion\": \"meta-apply.v1\",\n \"applied\": 2,\n \"files\": [\"src/functions/todos.functions.ts\"],\n \"generatedMetaIsStale\": true\n}\n```\n\n## Human-readable tables (`pikku info`)\n\nFour subcommands only — `functions`, `tags`, `middleware`, `permissions`. Routes,\nchannels, schedulers and queues are not subcommands; they are the _transport_ column\nof `info functions --verbose`.\n\n```bash\nyarn pikku info functions --verbose --silent\nyarn pikku info tags --silent\nyarn pikku info middleware --verbose --silent\nyarn pikku info permissions --verbose --silent\n```\n\n`--silent` suppresses the banner and inspector diagnostics. It works, but it is not\ndeclared as an option, so every run also prints `Warning: Unknown option: --silent\n(ignored)` — the warning is wrong. Ignore that one line.\n\n`--limit N` caps rows (default 50); the footer says how many were withheld.\nOn `tags`, `--verbose` swaps counts for names; elsewhere it adds columns.\n", "pikku-meta/references/versioning.md": "# Pikku Function Versioning\n\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## The `pikku semver` command\n\n`versions check` and `semver` answer different questions and share no state.\n`check` is a within-repo gate — \"you changed a contract without bumping\n`version:`\". `semver` is a release question — \"what does this build owe the\nclients of the one already deployed?\" — and needs an **external baseline**,\nwhich is why the answer is always relative to an environment rather than to\nthe previous commit.\n\n```bash\nnpx pikku semver --against https://api.acme.com/surface.json # vs production\nnpx pikku semver --against ../other-app/.pikku # vs a checkout\nnpx pikku semver --emit --out surface.json # publish a baseline\nnpx pikku semver --against ... --fail-on major # CI gate\n```\n\n`--against` takes three things and tells them apart itself: a directory is read\nas a `.pikku` tree, an `http(s)` URL is fetched as a published snapshot, and any\nother file is read as a snapshot. `--emit` produces the snapshot; **use `--out`**\n— plain `--emit` writes to stdout _after_ the CLI banner, so a bare `> file.json`\ncaptures the banner too. Publish the snapshot from CI on deploy and it becomes\nthe baseline everyone else compares against.\n\nThe verdict, in order:\n\n- **major** — a function or client-facing wiring was removed, or a surviving\n one tightened its contract.\n- **minor** — anything was added, or a contract loosened compatibly.\n- **patch** — the surface did not move; the release is internal work.\n\nBelow the id level it reads the generated JSON Schemas, and **direction\ndecides**. An input is contravariant (the caller writes it) and an output is\ncovariant (the caller reads it), so the same edit is not the same event on both:\n\n| Change | On an input | On an output |\n| ------------------------------ | ------------------------------------ | ------------ |\n| field removed | breaking (when the schema is closed) | breaking |\n| required field added | breaking | compatible |\n| field became optional | compatible | breaking |\n| optional field became required | breaking | compatible |\n| enum value removed | breaking | compatible |\n| enum value added | compatible | breaking |\n| type changed | breaking | breaking |\n\nIt consumes `versions.pikku.json`: published versions are immutable, so a\nfunction id that left the source while the manifest still records it is a `@vN`\nbump, not a removal — that is what keeps a deliberate version bump at `minor`.\nWithout the manifest the same disappearance reads as `major`, which is the safe\nreading rather than a wrong one.\n\nTwo things it deliberately will not guess. A named schema whose body did not\ntravel with the baseline falls back to `contractHash` and, if that moved, is\nreported **breaking with the reason stated** — never quietly \"unchanged\". And at\nthe wiring level only `auth` going from absent/false to true is classified as\nbreaking; every other metadata change is reported as compatible, because there\nis no general way to tell a cosmetic wiring edit from a restricting one.\n\nOutput is `.pikku/changes.gen.json` (override with `--out`), so it rides the\nsame meta pipeline as `audit.json`:\n\n```json\n{\n \"schemaVersion\": 1,\n \"baseline\": \"https://api.acme.com/surface.json\",\n \"verdict\": \"major\",\n \"summary\": { \"breaking\": 1, \"added\": 1, \"removed\": 0, \"modified\": 1 },\n \"changes\": [\n {\n \"kind\": \"function\",\n \"id\": \"getUser\",\n \"status\": \"modified\",\n \"breaking\": true,\n \"reasons\": [\"input.tenant: required field added\"]\n }\n ]\n}\n```\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 # Refuse to ship a breaking change to production unintentionally.\n - run: npx pikku semver --against https://api.acme.com/surface.json --fail-on major\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-meta/SKILL.md": "---\nname: pikku-meta\ndescription: >-\n Use to inspect or evolve a project you did not just write — `pikku meta` and `pikku info` for\n what the project declares (functions, schemas, wires, workflows, middleware, permissions) and\n `pikku meta apply` to change it, `pikku versions` / `pikku semver` for contract hashes,\n breaking-change detection and the semver a release should get, and `pikku audit` / `pikku\n update` for dependency advisories and moving Pikku forward. TRIGGER when: user asks what\n functions or routes exist, wants a function's input/output shape, wants to retag a function or\n set config on a declaration, asks about API versioning, breaking changes, what semver a release\n deserves, dependency vulnerabilities, the console Security screen, or upgrading Pikku. DO NOT\n TRIGGER when: user is writing a new function or wiring (use the wiring skill) or asking about\n Pikku concepts (use pikku-concepts).\ninstallGroups: [core]\nallowed-tools: Bash(yarn pikku meta *), Bash(yarn pikku info *)\nargument-hint: '[context|functions|schemas|workflows|middleware|permissions|wires|apply|versions|semver|audit|update]'\n---\n\n# Pikku Project Metadata\n\nThe project already knows what it declares. Ask it rather than grepping for it,\nand change it through the write path rather than by hand.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Asking what exists, or setting config on a declaration | `references/meta.md` |\n| Versioning a contract, or deciding a release's semver | `references/versioning.md` |\n| Chasing a dependency advisory, or upgrading Pikku | `references/audit.md` |\n\n## Start with `pikku meta context`\n\nIt answers in one call what a planner needs — functions, wires, middleware,\npermissions, workflows, capabilities, layout. Reach for `pikku meta` when you\nare going to act on the output and `pikku info` when a person will read it;\nthey are the same ground in two shapes.\n\n## Direction decides whether a change is breaking\n\nAn input is contravariant (the caller writes it) and an output is covariant (the\ncaller reads it), so the same edit is not the same event on both. Adding a\nrequired field breaks an input and is compatible on an output; making a field\noptional is the reverse. `pikku semver` reads the generated JSON Schemas with\nthat asymmetry built in, so let it decide rather than eyeballing a diff.\n\n## What NOT to do\n\n- **Do not infer a function's input or output by reading its body**, and do not\n cast a call site to make it compile. The schema is the type; `pikku meta\n functions get <id>` has it.\n- **Do not expect an unversioned function to be promoted for you.** Without an\n explicit `version: 2` it is version 1 of its contract, collides with the\n pinned `@v1`, and `pikku versions check` reports the published contract as\n modified.\n- **Do not reach for `override` by default.** The contract key already drops a\n matching `V<n>` suffix from the export name, so `getBookV1` keys under\n `getBook`. `override` is for an export that cannot follow that convention.\n- **Do not shell out to the package manager from a function.** The audit is a\n generated artifact — read `.pikku/audit.json` through\n `metaService.readFile('audit.json')`.\n- **Do not redeclare the audit report's shape.** `SecurityAuditReport` and its\n companions come from `@pikku/core`; the CLI writes it, the addon reads it, the\n UI renders it.\n- **Do not treat a failed audit run as a clean one.** `bun audit` exits non-zero\n when it *finds* advisories and still writes its payload, so non-zero with\n output is data; non-zero with no output throws on purpose.\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(\n 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\n .selectFrom('apiKey')\n .select('userId')\n .where('key', '=', header)\n .executeTakeFirst()\n if (row) setSession?.({ userId: row.userId })\n\n return next()\n }\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, sessions or auth strategies like authBearer/authCookie (use\n pikku-auth), 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/middleware'\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\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/error`.\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('*', [\n cors({ origin: 'https://app.example.com', credentials: true }),\n])\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/middleware'\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## ⛔ MACHINE AUTH: THE TOKEN BECOMES A SESSION. ⛔\n\n**A caller that has an identity — a sandbox, a deployed stage, a pool host, a device — is authenticated ONCE, in middleware, which calls `setSession`. The function is then an ordinary `pikkuFunc` gated with `scopes`. It reads `session`. It NEVER re-derives who the caller is.**\n\nEither a function is sessionless (genuinely public) or it has a session. Anything in between — a token verified inside `func`, a token verified in a `permissions` check that returns `true`, an identity passed in the input schema, the same resolver memoised per request so N functions can each call it — is the anti-pattern this section exists to kill.\n\n```typescript\n// middleware.ts — resolve the bearer ONCE, for every route\nconst sandboxBearerAuth = pikkuMiddleware<SingletonServices>(\n async ({ kysely, auth }, { http, getSession, setSession }, next) => {\n if (await getSession?.()) return next()\n const header = http?.request?.header?.('authorization')\n if (!header?.startsWith('Bearer ')) return next()\n const sandbox = await resolveSandboxSession(kysely, auth, header.slice(7).trim())\n if (sandbox) {\n setSession?.({ userId: sandbox.createdByUserId ?? sandbox.sandboxInstanceId,\n orgId: sandbox.organizationId, scopes: ['machine:sandbox'], sandbox } as UserSession)\n }\n return next()\n },\n)\n\naddHTTPMiddleware('*', [cors(...), betterAuthSession(), apiBearerAuth, sandboxBearerAuth as any])\n```\n\n```typescript\n// functions/report-something.function.ts\nexport const reportSomething = pikkuFunc({\n expose: true,\n scopes: ['machine:sandbox'], // ← the gate. Enforced by the runner, seen by the inspector.\n input: ReportSomethingInput,\n output: ReportSomethingOutput,\n func: async ({ kysely }, input, { session }) => {\n const sandbox = sandboxOf(session) // ← narrowing only, no verification\n ...\n },\n})\n```\n\nAn unresolved token leaves the session unset and the function throws `MissingSessionError` — 401, for free. Declare the scope tree once with `defineScope` (see `pikku-auth`).\n\n### It MUST be `addHTTPMiddleware`, never `addTagMiddleware`\n\n**A session set in tag middleware is invisible to the function when the call arrives over `POST /rpc/:rpcName`.** Tag middleware runs inside `runPikkuFunc`, and the RPC dispatch calls it without a `sessionService`, so `invocationWire.session` is never re-read after your `setSession` — the function sees the session the OUTER wire had, which is none. `addHTTPMiddleware('*')` runs on the `/rpc` route itself, before its handler, and that session is the one the dispatched function inherits. Tag middleware is still right for a gate that only says yes/no.\n\n### A cron is a machine identity too — set it in the task's own middleware\n\nA scheduled task has no caller and no header, but it is still a machine principal, and without a session it cannot invoke a gated RPC or be attributed in anything it writes. Give it one the same way, in the task's own `middleware`:\n\n```typescript\nconst cronSession = pikkuMiddleware(async (_services, { scheduledTask, setSession }, next) => {\n setSession?.({ userId: `cron:${scheduledTask?.name}`, scopes: ['machine:cron'] } as UserSession)\n return next()\n})\n\nwireScheduler({\n name: 'tickVirtualUserSchedules',\n schedule: '*/15 * * * *',\n middleware: [cronSession],\n func: tickVirtualUserSchedules,\n})\n```\n\nOne `const`, not a `machineSession(name)` factory: the inspector rejects a bare `pikkuMiddleware()` that is not assigned to a variable or object property, and the task name is on the wire anyway. Parameterised middleware goes through `pikkuMiddlewareFactory`.\n\nThe task can then be a thin `rpc.invoke('someGatedRpc')` against the same entry point a person calls, instead of factoring the logic into a `lib/` helper purely to route around the missing identity.\n\nUnlike tag middleware over `/rpc`, this works: `runScheduledTask` builds its wire with a `sessionService`, so the session set here is the one the function is frozen with. And unlike a person, a cron is **not** a user row — inventing a seeded account for it buys a phantom member in every list, seat count and bill, and a per-org membership that a cross-org sweep has to ignore anyway. Platform-wide authority is a scope, not a membership.\n\n### The one sessionless exception: bootstrap\n\nAn endpoint that runs BEFORE the caller has an identity — registering a new host with a shared bootstrap key, a login, a device-code request — has no session to set. That one stays `pikkuSessionlessFunc` and declares its gate in `permissions` (see `pikku-auth`).\n\n## Service-to-Service Bearer Auth (gate-only pattern)\n\nUse this when the callee needs to know only THAT the caller is trusted, not WHICH caller it is. If it needs to know which, use the session pattern above.\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-auth`), 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) => {\n _token = t\n}\nexport const getToken = () => _token\n```\n\n```typescript\n// wirings/http.wiring.ts\nimport { timingSafeEqual } from 'node:crypto'\nimport { addTagMiddleware, pikkuMiddleware } from '#pikku/middleware'\nimport { UnauthorizedError } from '#pikku/error'\nimport { getToken } from '../lib/host-token.js'\n\nconst bearerAuth = pikkuMiddleware(async (_services, { http }, next) => {\n const authHeader =\n http?.request?.header?.('authorization') ||\n 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-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\": {\n \"gmailOAuth2\": { \"id\": \"...\", \"name\": \"Personal Gmail\" },\n },\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()`:\n\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\n```ts\nimport { messageSend } from '@pikku/addon-email-gmail'\nexport const agentGmailtool__sendAMessageInGmail = messageSend\n```\n\nDefault is delete + retarget; wrappers add maintenance burden.\n\n**B) `isAgentTool: false`** — the stub is a graph node:\n\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\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(\n 'Stub: ported from n8n Code node \"Custom Code\" — implement me'\n )\n },\n})\n```\n\nAfter:\n\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\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\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\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-permissions/SKILL.md": "---\nname: pikku-permissions\ndescription: >-\n Use when deciding WHO may call a function — resource ownership, role gates, admin-only actions, or any \"only their own rows\" rule. Covers the `permissions` field, `pikkuPermission`, `pikkuAuth`, scopes, and where ownership belongs versus where it does not.\n TRIGGER when: writing or reviewing any function that touches a row a user owns, gating an action on a role, building the permissions half of a contract in build PHASE 2, or about to write an `if` in a function body that decides whether the caller is allowed.\n DO NOT TRIGGER when: the question is how to sign someone in or seed a persona (that is pikku-auth), or how to shape a paginated list (that is pikku-list-query).\ninstallGroups: [core]\n---\n\n# Pikku Permissions\n\n## The rule\n\n**Authorization goes in the `permissions` field. Never in the `func` body.**\n\n`permissions` runs before `func`, and it is DECLARED — `pikku meta` and the auditor can\nsee it. An `if` in the body is the same check, invisible: nothing can tell you which\nfunctions are gated or how, and the next person to add a caller gets no warning.\n\n```typescript\n// RIGHT\nexport const deleteBook = pikkuFunc({\n permissions: { owner: isBookOwner },\n func: async ({ kysely }, { bookId }) => {\n await kysely.deleteFrom('book').where('bookId', '=', bookId).execute()\n },\n})\n\n// WRONG — the gate is buried in the body\nexport const deleteBook = pikkuFunc({\n func: async ({ kysely }, { bookId }, { session }) => {\n const book = await kysely.selectFrom('book')...executeTakeFirst()\n if (book?.ownerId !== session.userId) throw new UnauthorizedError()\n await kysely.deleteFrom('book').where('bookId', '=', bookId).execute()\n },\n})\n```\n\n## `auth: true` IS NOT OWNERSHIP — this is the one people get wrong\n\n`auth: true` means \"somebody is signed in\". It does NOT mean \"this row is theirs\". A CRUD\nfunction set to `auth: true` and nothing else lets ANY signed-in user delete ANY other\nuser's row by passing its id. Every function that takes a row id needs BOTH: `auth: true`\nfor the session, and a `permissions` entry for the ownership.\n\nEqually: do NOT write an `isSignedIn` permission that returns `!!session`. That re-checks\nauthentication, which `auth: true` already did. A permission answers *may this user do\nthis* — role, ownership, tier — never *is there a session*.\n\n## Single row vs list — where ownership actually goes\n\nThis is the distinction to get right, and both halves are correct code:\n\n- **A function taking a row id** (`get`, `update`, `delete`) — ownership is a\n PERMISSION. Load the row, compare the owner to the session. It is a yes/no question\n about one row, which is exactly what a permission is.\n- **A function returning many rows** (`list`, `search`, any stats query) — ownership is\n a `WHERE` clause in the query, because \"only their rows\" is a filter, not a yes/no.\n There is no permission to write here; scoping the query IS the enforcement.\n\nA list that fetches everything and then filters in JS is a bug, not a permission.\n\n## Writing the checkers\n\nPut them in `src/permissions/`, one file per entity, and reuse one checker across every function on that\nentity rather than writing a near-copy per function.\n\n```typescript\n// src/permissions/book.ts\nimport { pikkuPermission, pikkuAuth } from '#pikku/auth'\n\n// Data-aware: gets the input, so it can load the row the caller named.\nexport const isBookOwner = pikkuPermission(\n async ({ kysely }, { bookId }, { session }) => {\n const book = await kysely\n .selectFrom('book')\n .select('ownerId')\n .where('bookId', '=', bookId)\n .executeTakeFirst()\n return book?.ownerId === session?.userId\n },\n)\n\n// Session-only: no input needed. Use for role and flag gates.\nexport const isAdmin = pikkuAuth(async (_services, session) => session?.role === 'admin')\n```\n\n## OR and AND\n\n```typescript\npermissions: {\n owner: isBookOwner, // OR — an owner may\n admin: isAdmin, // OR — an admin may\n editor: [isAdmin, isBookOwner] // AND — both, inside one group\n}\n```\n\nGroups are OR'd; entries inside a group array are AND'd.\n\n## Roles\n\nIf the app has roles, the role lives on the session (see pikku-auth / `mapSession`) and\nevery mutating or admin-only function names it in `permissions`. Gate the FUNCTION — hiding\nan admin button in the UI is UX, never enforcement, and a member who guesses the RPC name\ngets straight through if the function itself is open.\n\n## Scopes\n\n`scopes: ['admin:invoices:void']` is an AND gate checked BEFORE permissions and before\ninput validation. Declare the tree once with `defineScope`; a function naming an\nundeclared scope fails codegen rather than gating on nothing. A grant satisfies a scope if\nit is that scope, an ancestor, or a wildcard — a session holding `admin` satisfies\n`admin:invoices:void`. Most apps need roles, not scopes; reach for these only when the\nplan asked for granular grants.\n\n## The one sanctioned exception\n\n`permissionsInBody: true` — for a check that genuinely cannot be a permission because the\nidentity arrives in the payload and there is no session: a webhook signature, a signed\ntoken, an invite code. It is purely declarative and enforces nothing; its job is to tell\nthe auditor the openness is deliberate. Anything expressible as a permission must be one.\n\n## After changes\n\n`pikku all` — regenerates and typechecks the checkers. A permission whose signature is\nwrong fails here, not at runtime.\n", "pikku-react/references/client.md": "# Pikku React\n\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-wiring**), 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-wiring**.\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 | `references/workflows.md` |\n| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-wiring**) |\n\nThe first three live in your generated `api.gen.ts` (see the\n`references/react-query.md`). This reference 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\n`transformDate: true` revives **fully-zoned ISO-8601 instants** — `2026-03-14T08:12:00Z`,\n`2026-03-14T08:12:00.000+01:00` — into `Date` objects. Nothing else is touched: a bare\n`2026-03-14`, a zoneless `2026-03-14T08:12:00`, and a shaped-but-impossible\n`2026-02-31T00:00:00Z` all stay the strings the server sent, because each names a reading\nrather than a moment and `new Date` would guess a different instant per machine.\n\nSo the field's runtime type follows the VALUE, not the schema — one `z.string()` column can\narrive as a `Date` from one row and a string from the next. Two consequences, both of which\ntypecheck:\n\n- **A string method on a revived field throws at runtime.** `row.createdAt.split('T')[0]` —\n there is no string to slice.\n- **A raw `Date` in JSX crashes the route.** `<span>{row.createdAt}</span>` throws\n `Objects are not valid as a React child (found: [object Date])` and the page falls into its\n error boundary — a white screen, with nothing catching it first.\n\nFormat before rendering, with whatever date library the project already uses, and let it take\neither type. Coercing instead (`` `${d}` ``, `String(d)`) does not crash but prints\n`Mon Jun 15 2026 02:00:00 GMT+0200`, which is a different bug.\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### Dev actor sign-in (`useDevActors`)\n\nThe dev-only \"Sign in as …\" control: one click signs in as a declared scenario\npersona with no password, so the app can be reviewed as each kind of user.\n`pikku fabric validate` **requires** any frontend with a login screen to ship one\n(`app-missing-actor-quick-login-<app>`) — without it a reviewer is locked out of\ntheir own sandbox.\n\n```tsx\nimport { useDevActors } from '@pikku/react'\n\nconst { actors, signInAs, pendingEmail, isPending, error } = useDevActors({\n // Gate both reads on the bundler's dev flag so no credential can reach a\n // production bundle. The sandbox dev server bakes them from your personas.\n actors: import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined,\n secrets: import.meta.env.DEV\n ? import.meta.env.VITE_DEV_ACTOR_SECRETS\n : undefined,\n apiUrl: apiUrl(),\n onSignedIn: () => navigate({ to: '/' }),\n})\n```\n\n- **It is UI-free**, so render it however you like. For the default rendering use\n `<DevActorSwitcher />` from `@pikku/mantine/dev` — a separate entry point from\n `@pikku/mantine/core`, whose contract is \"drop-in alias for `@mantine/core`\"\n and so must not export components Mantine has no counterpart for.\n- **`secrets` is `{ address: credential }`, not one shared value** — a\n credential opens the one persona it was minted for (see\n **pikku-auth**). `actors` is empty unless the host supplied both a list\n and the credentials for it, and an actor with no credential is not offered, so\n a production build renders nothing without you testing for it.\n- **It takes `onSignedIn` rather than a router**, and takes the env values rather\n than reading them, because how env is spelled is a bundler fact\n (`import.meta.env.VITE_*` vs `process.env.NEXT_PUBLIC_*`).\n- The underlying `signInAsActor()` and `parseDevActors()` are exported too, for a\n non-React caller. The endpoint only accepts rows flagged `actor: true`, so it\n can never impersonate a real user — see **pikku-auth**.\n\nDo not hand-write the `devActors()` / `signInAsActor()` pair per app; that\ncopy-paste, including the `import.meta.env.DEV` gate, is exactly what this\nreplaced.\n\n### Linking from a Mantine element: `renderRoot`, not `component`\n\nHanding TanStack's `Link` to a Mantine element as `component={Link}` compiles,\nrenders, and navigates — and silently unties the type. Mantine's polymorphic\n`component` prop widens the router generic to `AnyRouter`, so `to` and\n`params` stop being checked against your actual routes. Renaming a route then\nbreaks the running app instead of the build, which is the one thing the typed\nrouter exists to prevent.\n\nWrap the typed `Link` once and reach it through `renderRoot`, which passes the\nprops through without re-typing the element:\n\n```tsx\n// components/links.tsx — one wrapper the whole app links through\nimport { Link } from '@tanstack/react-router'\n\nexport const AssessmentLink = (props: { assessmentId: string; children: React.ReactNode }) => (\n <Link to=\"/assessments/$assessmentId\" params={{ assessmentId: props.assessmentId }}>\n {props.children}\n </Link>\n)\n```\n\n```tsx\n<Button renderRoot={(p) => <AssessmentLink assessmentId={id} {...p} />}>\n Open\n</Button>\n```\n\nThe wrapper is where `to` and `params` are checked, and it is checked once.\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/references/react-query.md": "# Pikku React Query Hooks\n\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 `references/client.md`. 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 `references/workflows.md`.\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-react/references/workflows.md": "# Pikku Workflows — Client Hooks\n\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`references/client.md` and `references/react-query.md`).\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-react/SKILL.md": "---\nname: pikku-react\ndescription: >-\n Use when a React frontend talks to a Pikku backend — PikkuProvider and createPikku at the app\n root, the generated React Query hooks (usePikkuQuery, usePikkuMutation, usePikkuInfiniteQuery),\n direct usePikkuRPC / usePikkuFetch calls, realtime subscriptions, agent and workflow hooks, and\n the dev actor switcher. TRIGGER when: writing a React component that fetches or mutates backend\n data, wiring PikkuProvider, paginating, running or tracking a workflow from the client, or\n asking about useDevActors / VITE_DEV_ACTORS / quick login. DO NOT TRIGGER when: working on the\n backend (use pikku-wiring), defining the workflow itself (use pikku-workflow), or writing\n user-facing copy (use pikku-i18n).\ninstallGroups: [client]\n---\n\n# Pikku React\n\nThe hook names and their argument types come from your generated `api.gen.ts` —\nread it for what this app actually exposes. This skill is the part it cannot\ntell you: which hook a given need calls for, and where the generated client\nstops.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Wiring the app root, resolving the server URL, authenticating, or subscribing to realtime | `references/client.md` |\n| Fetching, mutating or paginating data | `references/react-query.md` |\n| Starting a workflow and showing its progress | `references/workflows.md` |\n\n## Reach for what\n\n| Need | Use |\n| --- | --- |\n| Render data, dedupe and cache | `usePikkuQuery` |\n| Trigger a write and wait for the result | `usePikkuMutation` |\n| Paginate | `usePikkuInfiniteQuery` |\n| One-off call from an event handler | `usePikkuRPC()` |\n| Hit a REST endpoint rather than an RPC | `usePikkuFetch()` |\n| Talk to one named AI agent | `usePikkuAgent(name)` → `.run` / `.stream` / `.approve` |\n| Run one named workflow | `usePikkuWorkflow(name)` → `.start` / `.run` / `.status` |\n| A workflow long enough to need progress UI | `references/workflows.md` |\n| Subscribe to events, SSE or a channel | `usePikkuRealtime()` |\n\nA workflow that finishes in a moment can be awaited; one that does not needs\nstart-plus-observe, or the component holds a pending state with nothing to show.\n\n## What NOT to do\n\n- **Do not write a client.** The generated one covers every exposed function\n with full types; a hand-rolled RPC client or a hand-written\n `useQuery({ queryKey, queryFn })` reimplements it worse.\n- **Do not instantiate `PikkuFetch`/`PikkuRPC` in a component.** `createPikku`\n runs once at the app root and the instance flows through context — and\n `usePikkuRPC()` outside `<PikkuProvider>` throws.\n- **Do not call the RPC client inside a `useEffect`.** The hooks handle\n deduplication, caching and unmounting; a manual effect handles none of them.\n- **Do not construct a hook name at runtime.** Hook names are the RPC names known\n at generation time, and a computed one is not type-checked.\n- **Do not poll a workflow with `setInterval`.** `useWorkflowStatus` with a\n `refetchInterval` callback dedupes across components and stops on a terminal\n state in one place.\n- **Do not reach for `as any` when a hook's types disagree with you.** The\n mismatch is the backend's input/output schema; fix it there.\n- **Do not hardcode a user-facing string.** Every display string goes through an\n i18n message — see `pikku-i18n`.\n", "pikku-realtime/SKILL.md": "---\nname: pikku-realtime\ndescription: >-\n Use when making ANY view live/realtime in a Pikku app — a board, shared list, dashboard, ticker, bidding room, live count — or when adding two-way chat/presence. Covers the DEFAULT event-hub SSE path and the two-way WebSocket channel.\n TRIGGER when: the user wants live updates, realtime, \"update without refresh\", a live board/feed/ticker/room, presence, or chat; or when data that MORE THAN ONE signed-in user can change should reflect others\n DO NOT TRIGGER when: a plain one-shot query/refetch is fine (data only one user changes, or a manual refresh is acceptable), or for background jobs (that is pikku-schedule/pikku-workflow).\ninstallGroups: [core, client]\n---\n\n# Pikku Realtime (SSE + WebSocket channels)\n\nThere is NOTHING to hand-roll and NOTHING to \"find\". The event-hub SSE transport\nis already wired into every app, and the two patterns below ARE the realtime\ntemplates. Start from them and rename — never grep the project for existing\n`sse`/`eventHub` code to copy, never write a custom `EventSource`, and never\nwrite a bespoke `sse: true` route for a plain live feed.\n\n## Pick the transport (almost always SSE)\n\n- **Server → client live updates → SSE via the event-hub.** This is the DEFAULT\n for making any view live: a board, list, dashboard, ticker, feed, or a \"room\"\n (a bidding room, sale room, live auction). The client only RECEIVES — the\n change itself happens through a NORMAL HTTP RPC (`placeBid`, `updateLot`, …)\n that publishes the new row.\n- **Client → server push mid-session → a WebSocket channel.** ONLY when the\n BROWSER must send up the socket without a page action: live chat messages,\n typing indicators, cursors/presence.\n\nA screen being called a \"room\", or being multi-user, or being live is NOT a\nreason to use a channel. If the browser isn't pushing frames up, it's SSE.\n\n## Level 1 — live updates (event-hub SSE, the default)\n\nTwo halves; both are required or nothing arrives.\n\n**Backend — publish after every write.** In each create/update/status function,\nAFTER the DB write, publish the changed row on a topic:\n\n```ts\nconst lot = await kysely\n .updateTable('lot')\n .set({ status: 'sold' })\n .where('id', '=', input.lotId)\n .returning(['id', 'status', 'currentBid', 'updatedAt'])\n .executeTakeFirstOrThrow()\nawait eventHub.publish('lot-updated', null, { topic: 'lot-updated', data: lot })\nreturn lot\n```\n\n**A topic is PUBLIC — publish a projection, never `returningAll()`.** The generated\n`/events/:topic` route is wired `auth: false` with a sessionless handler, so anyone who can\nreach the origin can subscribe to any topic name and read every frame on it. `returningAll()`\nthen ships the whole row — `reservePrice`, `sellerId`, internal notes, whatever the table\ngrows next — to unauthenticated subscribers, and it does it silently because the RPC's own\n`output` schema never sees the event payload. List the columns the topic is FOR, the way the\nexample does. If a change genuinely has per-viewer content, it does not belong on a topic:\npublish an id-only \"something changed\" frame and let each client refetch through an\nauthenticated RPC that applies its own permissions.\n\nThe **2nd arg is the channel to EXCLUDE** from the broadcast: pass `null` from a\nnormal HTTP/RPC write (there is no one to skip); pass `channel.channelId` ONLY\nwhen you publish from INSIDE a channel handler, or the sender gets an echo of its\nown update. `eventHub` is already injected — do not wire it.\n\n**Frontend — subscribe over SSE.** The generated\n`PikkuRealtime.subscribeToTopic(topic, handler)` opens an SSE stream to the\nbuilt-in `/events/:topic` route. Seed state from a normal query, then patch it as\nevents arrive; the event is the `{ topic, data }` envelope, so read `.data`.\n\n```tsx\nimport { useEffect } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { realtime } from '../lib/pikku'\n\nexport function useLiveLots() {\n const queryClient = useQueryClient()\n\n useEffect(() => {\n const subscription = realtime.subscribeToTopic('lot-updated', () => {\n queryClient.invalidateQueries({ queryKey: ['listLots'] })\n })\n return () => subscription.close()\n }, [queryClient])\n}\n```\n\n**Invalidate; do not hand-patch the cache.** The generated hooks key a query as\n`[name, input]` — `['listLots', { status: 'open', cursor: undefined }]`, one entry per set of\narguments — so `setQueryData(['listLots'], …)` writes to a key nothing reads and the screen\nnever changes. `invalidateQueries({ queryKey: ['listLots'] })` prefix-matches, so it refreshes\nevery variant of that list whatever input each one was fetched with.\n\nPatching also has to know the payload's shape, and a list RPC returns\n`ListOutput<Lot>` — `{ rows, nextCursor, totalCount? }`, not `Lot[]` — so a `rows.map(...)`\nupdater is reading `.map` off an object. Refetching sidesteps both, and it re-applies the server's own filtering,\nwhich a locally patched row does not: a lot that just moved to `sold` may no longer belong in\nan \"open lots\" list at all.\n\n`subscribeToTopic` returns `{ close }` — ALWAYS close on unmount or you leak the\nstream. Never hand-roll an `EventSource`.\n\n## Level 2 — two-way channel\n\nOnly when the client pushes up the socket. The backend channel lives in its own\n`*.channel.ts` with `onConnect`/`onMessage` handlers:\n\n```ts\nimport { pikkuChannelFunc, wireChannel } from '#pikku/channel'\n\nexport const onMessage = pikkuChannelFunc<{ text: string }>({\n func: async ({ eventHub }, input, { channel, session }) => {\n const message = { id: crypto.randomUUID(), text: input.text, userId: session!.userId }\n await eventHub.publish('room', channel.channelId, { topic: 'room', data: message })\n return message\n },\n})\n\nwireChannel({ name: 'room', route: '/room', auth: true, onMessage })\n```\n\nThe frontend opens it with `PikkuRealtime.connectToChannel(path)`, which returns\na socket you both `.send(...)` on and read via `onmessage`:\n\n```tsx\nuseEffect(() => {\n const socket = realtime.connectToChannel('/room')\n socket.onmessage = (event) => appendMessage(JSON.parse(event.data))\n return () => socket.close()\n}, [])\n```\n\nPublish server→client fan-out from a channel handler with\n`eventHub.publish(topic, channel.channelId, envelope)` — the 2nd arg excludes the\nsender, so the browser that sent the message does not receive its own echo.\n\n## Do NOT\n\n- Do **not** grep the project for existing SSE/eventHub infra to reverse-engineer\n or copy — the patterns above ARE the template (same rule as never reading\n `.gen.ts` to learn an API).\n- Do **not** write a custom `sse: true` HTTP route or a bespoke `EventSource` for\n an ordinary live feed — the event-hub covers it. (A dedicated `sse: true` route\n is only for a long-job PROGRESS stream, and is not needed for an initial build.)\n- Do **not** use a WebSocket channel for a live board/ticker/room — that is SSE.\n A channel is for client→server push (chat/presence) ONLY.\n- Do **not** forget the backend `eventHub.publish(...)` — a subscribed frontend\n with no publisher is a silent, empty stream.\n", "pikku-scenario/references/persona-run.md": "# Running a persona as a virtual user\n\n`pikku persona run <environment> <persona>` signs a declared persona in over the\napp's real auth and works the API in character, driven by a model. A persona\nwhile running **is** the virtual user — there is no second declaration for it.\n\n**It is not a test runner.** It asserts nothing, and a green run proves nothing:\nwhat it produces is _findings_, and their absence is only ever \"not this time,\nnot with this seed\". Findings set exit code 1, so a run can gate a pipeline;\ngiving up on a goal does not, because that is a user being a user.\n\nEverything it needs is already in the project — the catalogue is the function\nmeta, the intents are the scenarios' own prose, the identity is the persona\nsigning in, the scopes come from their declared roles. The only new input is\nwhich person to be.\n\nDeclaring personas — persona versus actor, `definePersonas`, materialised\nactors — is in the skill itself, under **Personas and actors**. This is the\nrunning half.\n\n## The shape of a run\n\n```bash\nSCENARIO_ACTOR_SECRET=… pikku persona run local shopper\nSCENARIO_ACTOR_SECRET=… pikku persona run local shopper -d careless --seed 42\nSCENARIO_ACTOR_SECRET=… pikku persona run staging auditor \\\n --goals \"reconcile the order totals\" --steps 80 --out runs/auditor.json\n```\n\nBoth arguments are required positionals: the environment key from\n`environments`, then the persona id. A run needs a model — `--model`, or\n`scenarios.model` in `pikku.config.json` — and an AI provider in the\nenvironment (`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or `LITELLM_PROXY_URL` +\n`LITELLM_API_KEY`).\n\n| Flag | Effect |\n| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--disposition` / `-d` | How they behave. Overrides the persona's own |\n| `--goals` | Comma-separated, in your words — run _alongside_ the persona's own and the ones derived from scenarios |\n| `--steps` | Model turns before it stops (default 40) |\n| `--mutations` | Non-read calls before it stops |\n| `--duration` | Wall clock before it stops, e.g. `30m` |\n| `--seed` | Replay — the same seed schedules the same run |\n| `--model` | The model they think with |\n| `--allow-approval` | Offer the endpoints the app marked as needing a human's approval. Off by default: those are the ones that spend money |\n| `--skip-role-check` | Start without verifying declared roles against the stage |\n| `--api-url` | Override the environment's `apiUrl`, for a target that only exists at run time. It replaces the url, not the environment's classification — see below |\n| `--out` | Write the whole run — every step, response and finding — as JSON |\n\n## The dispositions\n\nA disposition is a bundle of instructions and mechanical dials (move weights,\ntemperature, repeat and re-read rates). `tuning` on the persona adjusts those\ndials without replacing the character — a tuned `careless` user is still\ncareless. Passing `--disposition` drops the persona's `tuning`, because you\nasked to run them differently rather than to bend their dials into another\nshape.\n\n| Disposition | Who that is |\n| ------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| `realistic` | The default. A competent user reading schemas and entering plausible values |\n| `careless` | Busy, interrupted, half-remembering; submits twice, enters odd-but-legal values. **Where most production bugs actually live** |\n| `newcomer` | First time here, holds no ids in their head, must find a path from the lists that exist (`emptyMemory`) |\n| `stale` | Working from old notes — reaches for ids that may no longer resolve, to see how the product says so |\n| `auditor` | Reconciling, not achieving: reads one fact from every endpoint claiming to know it and reports disagreement. Read-only |\n| `adversarial` | Probing whether the boundaries are enforced. Inverted oracle — a 2xx from something it should not reach is the finding |\n| `accountable` | Doing the job for real. The **only** disposition production accepts |\n\n## Credentials, and which one wins\n\nThree variables, checked in this order. None of them belongs in\n`pikku.config.json`.\n\n1. **`FABRIC_OPERATOR_TOKEN`** — what a deployed stage accepts. Asymmetric, and\n it needs no account the target would not otherwise have, so it wins over the\n other two when both are present.\n2. **`PIKKU_PERSONA_SECRETS`** — `id=secret,id=secret`, already-derived\n per-persona credentials. Hand a run only the personas it should be able to\n be; asking for one outside the list is refused by name rather than falling\n through to the root. Mint them with `pikku persona secret [personas...]` —\n naming none mints all.\n3. **`SCENARIO_ACTOR_SECRET`** — the root secret, which derives every persona's\n credential and is therefore entitled to all of them. Only `pikku dev` serves\n the endpoint it opens.\n\n## Production is opt-in, twice\n\nA persona's `environments` omitted means every configured environment **except**\nthose flagged `production: true` — nothing reaches production by being\nforgotten. Naming one requires `disposition: 'accountable'`.\n\nThat rule is checked twice on purpose: the inspector checks the declaration at\nbuild time, and sign-in re-checks the **effective** disposition — the persona's\nown, or whatever `--disposition` replaced it with — before the run starts. So\n`--disposition adversarial` cannot point an accountable persona at production.\nThe build check trusts the file; the run check does not trust which artifact got\ndeployed.\n\n**That rule is keyed on the environment's name, not its url.** `production:\ntrue` is a label a person wrote in `pikku.config.json`; nothing can tell from a\nurl whether real customers are behind it. `--api-url` replaces the url and keeps\nthe classification, so a non-production environment repointed at a production\nhost is still treated as non-production, and an adversarial persona will happily\nrun against it. The flag is for a target that only exists at run time — a\nfreshly provisioned sandbox. Point it anywhere else and the guard above is not\nprotecting you.\n\n## The role check happens before the first step\n\nA run reads its own roles back from the stage and compares them to what the\npersona declared. It refuses on a mismatch, before anything runs — findings\nfrom a persona whose roles drifted are about the seed, and reading them as\nproduct bugs is how a whole run gets thrown away. A stage that reports no roles\nwarns and runs unverified. `--skip-role-check` is for a target whose auth\nreports roles somewhere pikku cannot read; findings from such a run may be seed\ndrift.\n\n## The other subcommands\n\n| Command | What it answers |\n| ------------------------------------ | ------------------------------------------------------------------------------------------ |\n| `pikku persona list` | Who is declared — who each one is, what they may do, what they want |\n| `pikku persona sync <environment>` | Which personas that environment will provision, with which roles, and why any were skipped |\n| `pikku persona secret [personas...]` | Mint per-persona credentials from the root secret |\n\n`sync` **reports**; it does not provision. The CLI has no connection to a\ndeployed environment's database, so the provisioning happens in the deployment —\npass the generated personas to `pikkuFabric` from `@pikku/better-auth`.\n\n## What NOT to do\n\n- **Do not treat a clean run as a pass.** Nothing was asserted. Use scenarios\n for the things that must hold.\n- **Do not run a persona declared `runnable: false`**, or one whose `account`\n names a provider. The first is someone who exists to be acted upon — running\n her races the scenario that bans her — and the second needs a human at a\n consent screen. Both are refused before sign-in rather than partway through.\n- **Do not use `--api-url` to reach a production host from a non-production\n environment.** The disposition guard reads the named environment's\n `production` flag, not the url you pointed it at, so nothing will stop you.\n- **Do not put any of the three credentials in `pikku.config.json`.** They are\n environment variables.\n- **Do not pass `--allow-approval` casually.** The endpoints behind it are the\n ones the app marked as needing a human because they spend money.\n- **Do not read a finding from a run started with `--skip-role-check` as a\n product bug** until the roles are confirmed some other way.\n- **Do not expect `--goals` to replace the persona's goals.** They are appended;\n a run that replaces Susan's goals is not Susan.\n", "pikku-scenario/SKILL.md": "---\nname: pikku-scenario\ndescription: >-\n Use when writing or running Pikku scenarios, running a persona as a virtual user, or when\n asked to test Pikku functions or improve coverage. A scenario (pikkuScenario) drives the app\n the way users do — steps run as actors over the real transport against a running server — so a\n flow doubles as an e2e test and a staged/production health check. Covers scenario.do /\n expectEventually / expectError / expectService / expectScore, declared steps via\n pikkuScenarioStep (browser steps driven by @pikku/playwright) written as intent rather than\n clicks, personas / actors / environments in pikku.config.json, SCENARIO_ACTOR_SECRET, the\n `pikku scenario list|run` and `pikku persona run|list|sync|secret` commands, and live coverage\n via `pikku dev --coverage`. TRIGGER when: user asks about scenarios, testing a Pikku function,\n coverage, e2e flows, browser/UI e2e, health checks, personas, virtual users, or adversarial\n runs against a stage. DO NOT TRIGGER when: user asks about running an existing suite (use\n Bash) or CI config.\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## Pick the reference\n\n| You are… | Read |\n| ---------------------------------------------------------------- | --------------------------- |\n| Writing or running scenarios | this skill |\n| Running a persona as a model-driven virtual user against a stage | `references/persona-run.md` |\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/scenarios'\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 `pikkuAgentScorer` (grades in code) or\n`pikkuAgentJudge` (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(\n 'asks for a summary',\n 'runAssistant',\n {\n prompt: data.prompt,\n },\n { actor: actors.user }\n)\n\nawait scenario.expectScore('answered briefly', runId, 'brevity', {\n atLeast: 0.8,\n})\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/scenarios'\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 shopper is browsing the shop` |\n| `When clicks the category filter` | `When 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/scenarios'\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 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### What language the prose is in\n\nA scenario carries two kinds of text, and they do not share a language.\n\n**Identifiers are English.** The exported const (`buysAnApple`,\n`credentialFeature`), the step's `name` — which is its `pikkuFuncId`, the typed\nstring the generated step map is keyed by — the file name, and every helper in\n`*.browser.ts`. These bind to generated code and to `pikku scenario list`; they\nare English in every project regardless of who the product is for or what\nlanguage the team speaks. There is no setting that changes this.\n\n**Prose follows `metaLocale` in `pikku.config.json`** (default `en`). That is a\nstep's `description` and `template`, a feature's `name` and `description`, a\nscenario's `title`, and the positional step names passed to\n`scenario.given/when/then`. Read the field before you write any of them.\n\nThis split is the same one the feature table already states — _the export\nidentifier is the feature's id; `name` is the human-readable label_ — applied to\nlanguage. The report is the deliverable, and it is read by the team; the\nidentifier is an API, and it is read by the toolchain.\n\n```typescript\n// pikku.config.json: { \"metaLocale\": \"de\" }\nexport const buysAnApple = pikkuScenarioStep<\n { qty: number },\n { orderId: string }\n>({\n name: 'buysAnApple', // identifier — English, always\n description: 'kauft einen Apfel', // prose — follows locale\n template: 'kauft {qty} Äpfel', // prose — follows locale\n actor: true,\n default: async (_services, { qty }, { actor }) =>\n await actor.invoke('placeOrder', { qty }),\n})\n```\n\nNote what does **not** change: `placeOrder` is still `placeOrder`, and the file\nis still `apple.scenario.ts`.\n\nA product with a non-English UI is not on its own a reason to set `metaLocale` — that\nis the app's language, not the team's. Ask, or leave it `en`.\n\n**Where a non-`en` `metaLocale` still shows English, today.** The reporter composes a\nsentence as `<Keyword> <actor> <template>` (`composeStepProse`), and the keyword is\nan English literal. The Console translates the Given/When/Then keywords into its own\nUI language; the CLI reporter does not, so `metaLocale: \"de\"` gives you German step\nprose inside an English frame — `Given shopper kauft 1 Äpfel`. Write templates that read\nacceptably in that frame rather than trying to defeat it. A second gap: where a\nfunction or scenario declares no `title`, the Console falls back to splitting the\n**identifier** into an English-looking label (`toEnglishName`), so under a\nnon-`en` `metaLocale` meta is worth authoring rather than leaving to the fallback.\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 // Both bindings run as the persona, so the step declares one and the runner\n // injects `wire.actor` — non-optional in every binding.\n actor: true,\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 // Through the actor, not through a `rpc` service — see \"What a step is given\".\n default: async (_services, { orderId }, { actor }) => ({\n status: (await actor.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/scenarios'\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 actor: true,\n default: async (_services, { qty }, { actor }) => {\n return await actor.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 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- **A step that runs as somebody declares `actor: true`**, and the runner injects `wire.actor` — non-optional inside every binding, with no guard to write and nothing to unwrap. A `browser` binding implies it, because a window is opened as somebody. Leave it off for a step with no persona to be: an assertion over what an earlier step returned, or one that posts credentials precisely because it must not reuse an actor's session. Dispatching a step that declared it without `{ actor: actors.x }` fails before the body runs (`ScenarioActorRequired`); a step that did not declare it has no `actor` on its wire at all.\n- **`env` is optional on the wire**, because most steps need nothing from it. Narrow it with `requireScenarioEnv(scenarioStep)` from `#pikku/scenario` rather than a local guard — it names the step and says 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- **Never write the actor into the prose.** The reporter renders the actor as the sentence's subject, so a step authored as `` `'sam' creates the client` `` run as `{ actor: actors.sam }` reads \"Given sam 'sam' creates the client\" — and the hardcoded name desyncs the moment the call site changes actor. Write a bare third-person predicate (`creates the {name} client`) and let the actor supply the subject. Prose that opens with its own actor's key — quoted, capitalised or possessive — is `PKU681`; naming someone **else** mid-sentence (\"sends nadia an invite\") is ordinary prose and is left alone, as is an actor keyed after a role noun used as a noun (\"creates the admin client\" as `actors.admin`).\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#### What a step is given\n\nA step has the signature of an ordinary pikku function, which makes it look as\nthough it runs where the application runs. It does not — **it runs in the CLI\nprocess**, and the services object is built there, by hand:\n\n```typescript\n{ logger, workflowService, workflowRunService, agentRunner? }\n```\n\nThat is the whole list. There is no `kysely`, no `variables`, no `secrets`, and\nnone of the project's own singleton or wire services. A step that destructures\none gets `undefined` and fails on first use — `Cannot read properties of\nundefined (reading 'selectFrom')` — which reads like a broken container and is\nnot.\n\n`rpc` is the trap worth naming, because it is present and it throws. It is a\n`guardRpc` whose every member refuses:\n\n> Scenario tried to run 'getOrder' as an internal step. Every workflow.do in a\n> scenario must carry { actor: actors.x } so it executes against 'local'.\n\nThe same guard covers `rpc.agent.run/stream/resume/approve/interrupt` and\n`startWorkflow`.\n\nThis is the design, not a gap: **everything a step touches of the application\ngoes over the wire as somebody.** A test that could reach into the database\nwould be testing a different program from the one a person uses. So there are\nexactly three ways in, and they are all through the actor:\n\n- `actor.invoke(name, data)` — typed over the exposed RPC map, carrying the\n actor's session. Declare `actor: true` and destructure it off the wire.\n- `.invokeRaw(name, data, { headers })` — same call, reporting\n `{ status, ok, body }`, for when the refusal is the assertion.\n- a plain `fetch` against `requireScenarioEnv(scenarioStep).apiUrl`, for\n anything not an RPC — a websocket, a file upload, a webhook.\n\nTwo consequences follow, and both shape how steps get written:\n\n- **A step cannot observe anything the app does not publish.** If a test needs a\n fact the client never sees, the fix is to emit it on the stream or expose it\n as an RPC — which usually improves the product, since a client debugging the\n same problem needed it too.\n- **`agentRunner` is conditional.** It is built only when the project declares\n agents, and `createDevAgentRunner` needs a base URL _and_ a key together\n (`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the LiteLLM pair). With a key alone\n it returns nothing and `agentRunner` is `undefined`, so `actor.converse`\n fails before the persona says anything. A suite that would rather own its own\n model can pass an `llm` to `runConversation` instead of relying on this one.\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<\n { path: string },\n { 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 (_services, _data, { actor }) => ({\n url: (await actor.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#### Locate by message key, never by rendered copy\n\nIf the app is translated, **no step may contain a user-visible string.** `getByLabel('Full Name')` passes only while the browser happens to render the base locale, and any copy edit turns it into a selector timeout that points at the wizard rather than at the rename that caused it — the test looks broken where it is merely stale.\n\nThe message catalogue already holds the string under a key. Read it from there. Type the lookup off the catalogue JSON so a renamed or misspelled key is a **compile** error rather than a run-time timeout:\n\n```typescript\n// tests/scenarios/i18n.ts\nimport type messages from '../../../../apps/web/messages/en.json'\n\nexport type MessageKey = keyof typeof messages\n\nexport const t = (key: MessageKey, locale = baseLocale): string => {\n /* … */\n}\n```\n\n```typescript\nawait page.getByLabel(t('jobs_apply_fullname')).fill(identity.name)\nawait page\n .getByRole('button', { name: t('jobs_apply_submit'), exact: true })\n .click()\n```\n\n- Type off `messages/<baseLocale>.json`, **not** the generated Paraglide output — `i18n/paraglide/` is build output, so typing against it makes the tests unbuildable until the app has been built. The JSON is the tracked source.\n- Fall back to the base locale for a key a locale has not translated. That is what Paraglide does at run time, so a helper that throws instead would disagree with the screen the test is looking at.\n- This is not only about locators. A copy literal passed to a **project helper** (`pick('Where would you like to work?', …)`) reaches the DOM the same way, and so does a pane name quoted back in a failure message. `pikku fabric validate` scans every string in a `*.steps.ts` / `*.scenario.ts` against the base catalogue and errors on any verbatim match, wherever it sits — except comments, and the `name` / `description` / `template` declared directly on a `pikkuFeature`, `pikkuScenario` or `pikkuScenarioStep`, which are Console meta written in the project's `locale` rather than app copy.\n- A regex locator (`{ name: /^Next$/i }`) hides the literal but not the problem. `{ name: t('key'), exact: true }` is both stricter and locale-correct.\n- Strings the catalogue does not own — a test id, a fixture filename, a seeded value — stay literal. The catalogue is the test for whether something is copy.\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\n#### Declaring personas in TypeScript\n\n`definePersonas({ … })` is the code form of the block above, and there may be\n**one call in the whole codebase** — one place to read the set from, one place\nto add to it. A second anywhere, including in the same file, is a critical.\nGenerated files are exempt and never claim the slot.\n\n> [!WARNING]\n> The declaration is **read from source, never evaluated** — the CLI writes it\n> to JSON that a deployed stage carries without the app. So every value has to\n> be statically knowable, and a value that is not comes out as `undefined`\n> rather than as an error. Only `name` is checked, so a computed `personality`,\n> `jobTitle` or `description` is dropped in silence and the persona runs with a\n> blank temperament.\n\nWhat that admits and what it does not:\n\n```typescript\npersonality: 'Wound up and short with it.' // read\npersonality: `Wound up and short with it.\n Says what she wants in a few blunt words.` // read — no ${} in it\npersonality: 'Wound up. ' + 'Short with it.' // dropped, silently\npersonality: TEMPERAMENTS.impatient // dropped, silently\n```\n\nA no-substitution template literal is a string literal as far as the reader is\nconcerned, so it is the way to write a long personality across several lines —\nnot a concatenation, and not a `prettier-ignore`d single line. Its newlines and\nleading indentation are kept verbatim and reach the model that way, which is\nharmless but worth knowing before you align it to the surrounding code.\n\nOne more thing worth knowing before writing a rich persona: **`actor.converse`\nbuilds its prompt from `name`, `jobTitle`, `personality` and the scenario's\n`task` only.** Fields like `disposition`, `goals` and `roles` are read and\nstored, and the console shows them, but they do not reach the conversing\npersona's instructions. Anything that must shape how someone talks belongs in\n`personality` or in the task.\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### The same actors sign a human in\n\nDeclared actors are not only for automated runs. `signInPath` is Better Auth's\n`actor` plugin (see `pikku-auth`, a separate install), which any caller can post to — so the\nfrontend gets a one-click \"Sign in as …\" switcher over the **same** list, and an\napp can be reviewed as each kind of user without anyone knowing a seed password.\n\nThe sandbox dev server bakes both halves into the frontend from the declared\npersonas: `VITE_DEV_ACTORS` (the JSON actor list) and `VITE_DEV_ACTOR_SECRETS`\n(`{ email: credential }`, one per persona — `SCENARIO_ACTOR_SECRET` itself never\ngoes in a bundle; see **pikku-auth**). Neither var is set in a production\nbuild, so the control renders nothing there — but gate the reads on your\nbundler's dev flag anyway (`import.meta.env.DEV ? … : undefined`) so no\ncredential reaches a production bundle in the first place.\n\nDo not hand-roll the switcher: `useDevActors()` (`pikku-react`, a separate install) is the logic and\n`<DevActorSwitcher />` from `@pikku/mantine/dev` is a ready rendering of it.\n`pikku fabric validate` **requires** any frontend with a login screen to ship\none — without it a reviewer is locked out of their own sandbox.\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\n```\n\n`scaffold.scenarios` is a boolean or `{ path? }` — whether the surface exists\nand where it is written. A bare string is **rejected by the config loader**, not\nreinterpreted: under a shape where a string could be a path, silently reading\none as a flag would 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>\": {/* FunctionCoverageReport */},\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 step named `kauftEinenApfel` / a `vorgang` table | Identifiers are English in every project. The German belongs in `description` / `template`, and only when `pikku.config.json` sets `metaLocale`. |\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| `getByLabel('Full Name')` in a translated app | Passes only in the base locale, and a copy edit breaks it as an unexplained timeout. Locate by message key. |\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-seo/SKILL.md": "---\nname: pikku-seo\ndescription: >-\n On-page SEO rules for the app's PUBLIC pages: per-route head() titles and meta descriptions, Open Graph tags, one-h1 heading hierarchy, semantic/crawlable markup, JSON-LD on the landing page, and noindex for the logged-in area.\n TRIGGER when: building or reworking any public page (landing, pricing, about, blog/content pages), writing page titles or meta tags, or the user asks about SEO / Google / discoverability / social sharing previews.\n DO NOT TRIGGER when: working on logged-in /app screens (they are noindexed — only the one robots rule below applies), backend functions, database, or deployment.\ninstallGroups: [client]\n---\n\n# SEO Rules\n\nApps render SSR from the edge, so crawlers see full HTML — the ranking work is\ngetting the on-page signals right while you build. These rules apply to PUBLIC\nroutes only (the landing page and any marketing/content pages). The logged-in\n`/app` area is private: it gets `noindex` and nothing else from this skill.\n\n## Per-route head() — every public route, no exceptions\n\nTitles and descriptions live in TanStack Start's `head()` on the route, merged\nroot → leaf (the leaf's title/meta win). The root route already carries the\nsite-wide defaults and OG tags; every public page you add MUST override both:\n\n```tsx\nexport const Route = createFileRoute('/pricing')({\n head: () => ({\n meta: [\n { title: 'Pricing — Acme Scheduling' },\n {\n name: 'description',\n content:\n 'Simple per-seat pricing for Acme Scheduling. Start free, upgrade when your team grows — no setup fees, cancel anytime.',\n },\n { property: 'og:title', content: 'Pricing — Acme Scheduling' },\n { property: 'og:description', content: 'Simple per-seat pricing. Start free.' },\n ],\n }),\n component: PricingPage,\n})\n```\n\n`head()` strings are plain strings (they do not go through the Mantine i18n\ngate) — write real copy for THIS app, in the app's voice.\n\n- **Title**: unique per page, 50–60 characters, the page's primary topic first,\n brand at the end (`Topic — AppName`). The template's `__APP_TITLE__` default\n must never survive the rebrand, on any page.\n- **Description**: unique per page, 150–160 characters, states the concrete\n value of the page in plain language — a reason to click, not a keyword list.\n- **Dynamic public pages** (e.g. a public detail page) build both from loader\n data: `head: ({ loaderData }) => ({ meta: [{ title: `${loaderData.name} — AppName` }, ...] })`.\n- **Never invent URLs**: the deployed domain is unknown at build time, so do\n NOT emit `canonical`, `og:url`, or `og:image` pointing at a made-up domain —\n omit them (same principle as the `/api` serverUrl rule). `og:image` only if a\n real asset exists in the app.\n\n## Logged-in area = noindex\n\nThe `/app` route (the authenticated layout route) gets exactly one meta entry:\n\n```tsx\nhead: () => ({ meta: [{ name: 'robots', content: 'noindex' }] })\n```\n\nNever noindex a public page, and never put per-page SEO effort into `/app`\nscreens — they are invisible to crawlers by design.\n\n## Headings — exactly one h1 per page\n\n- Every page has EXACTLY ONE h1 (`<Title order={1}>` in Mantine, `<h1>` in\n Tailwind) and it names the page's primary topic — aligned with the title tag,\n not identical boilerplate.\n- Logical hierarchy below it: h1 → h2 → h3, no skipped levels, headings\n describe the content under them. Never pick a heading level for its font\n size — set the size on the correct level (`<Title order={2} fz=\"xs\">`).\n\n## Crawlable, semantic markup\n\n- Landmarks on public pages: `<nav>`, `<main>`, `<footer>` (Mantine: `component=\"nav\"` etc.).\n- Navigation between public pages uses real links (`<Link>`/`<a href>`) with\n descriptive anchor text — crawlers follow hrefs; a `div onClick` navigation\n is invisible to them. No public page may be orphaned: every public page is\n reachable by link from the landing page (directly or via nav/footer).\n- Every meaningful `<img>` has alt text describing the image; decorative images\n get `alt=\"\"`. Prefer descriptive file names for real assets.\n- Readable URLs: public routes are lowercase, hyphen-separated, and named for\n their content (`/pricing`, `/how-it-works`) — never `/page2` or query-param\n navigation.\n\n## JSON-LD on the landing page\n\nThe landing page carries one structured-data script describing the product.\nOnly mark up what is visibly true on the page — never invent ratings, reviews,\nor offers (fake schema is a Google penalty, not a boost):\n\n```tsx\nhead: () => ({\n meta: [\n /* title + description as above */\n ],\n scripts: [\n {\n type: 'application/ld+json',\n children: JSON.stringify({\n '@context': 'https://schema.org',\n '@graph': [\n { '@type': 'Organization', name: 'Acme Scheduling', description: '…' },\n { '@type': 'WebSite', name: 'Acme Scheduling' },\n ],\n }),\n },\n ],\n})\n```\n\nAdd further types only when the page genuinely IS that thing and shows the\nrequired fields: `FAQPage` for a real FAQ section, `Article` for a blog post\n(headline, datePublished, author), `Product`/`Offer` for a real price list.\nOmit `url`/`logo` fields — deployed domain unknown (see \"never invent URLs\").\n\n## Verify\n\nOpen each PUBLIC page and read its `<head>`: a title, a meta description, and\nexactly one `h1`. A public page is not done while any of the three is missing.\nSigned-in pages under `/app` are exempt once `noindex` is set — they are not\nindexed, so their head tags do not matter.\n\n## Don'ts\n\n- No keyword stuffing — write for the reader; one clear topic per page.\n- Don't duplicate the same title/description across pages (worse than absent).\n- Don't render SEO-critical copy only after client-side effects — it must be\n in the SSR HTML (loader data is fine; `useEffect`-fetched content is not).\n- Don't add robots.txt/sitemap plumbing — the platform owns that layer.\n", "pikku-service-backends/references/aws.md": "# AWS (`@pikku/aws-services`)\n\n```bash\nyarn add @pikku/aws-services\n```\n\nAWS-backed implementations of the content, queue, and secret interfaces.\n\n## `S3Content` — ContentService\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\nEvery method takes a single **args object**, matching the shared `ContentService`\ninterface. 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`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>` — it uses\n`bucketName` as the **host**. For signed content that value must therefore be\nyour CloudFront domain, not a plain bucket name, which means the same config\nfield is doing two jobs.\n\nPresigned upload URLs expire after a fixed **3600s**, not configurable through\nthe 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## `SQSQueueService` — QueueService\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\n- `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — returns SQS's `MessageId`\n- `getJob()` — always **throws**\n\nThe queue URL is `queueUrlPrefix + queueName`, so the name in `wireQueueWorker`\nhas to match the SQS queue exactly.\n\nConstraints inherited from SQS, enforced in `add`:\n\n- `options.delay` is in **milliseconds**, 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```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\n## `AWSSecrets` — SecretService\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\nSDK's default provider chain (instance role, env, profile) supplies those.\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>>` — missing keys are omitted rather than thrown\n- `hasSecret(SecretId: string): Promise<boolean>` — performs a full fetch\n- `setSecret` / `deleteSecret` — **not implemented**; they throw\n", "pikku-service-backends/references/backblaze.md": "# Backblaze B2 (`@pikku/backblaze`)\n\n```bash\nyarn add @pikku/backblaze\n```\n\n`B2Content` implements `ContentService` over Backblaze B2.\n\n```typescript\nimport { B2Content } from '@pikku/backblaze'\n\nconst content = new B2Content(config: B2ContentConfig, logger: Logger)\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\nEvery method takes a single **args object**, matching the shared `ContentService`\ninterface. 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\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\nBecause `writeFile` drains the whole stream into a `Buffer` before uploading\n(B2's upload endpoint needs a SHA-1 and a content length up front), large uploads\nshould go through `getUploadURL` and be sent by the client directly.\n\n```typescript\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-service-backends/references/mongodb.md": "# MongoDB (`@pikku/mongodb`)\n\n```bash\nyarn add @pikku/mongodb\n```\n\n## `PikkuMongoDB` — connection wrapper\n\nEvery service below takes a `Db`, so the wrapper is constructed and initialised\nfirst.\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| `MongoDBAgentStorageService` | `AgentStorageService`, `AgentRunStateService` | 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 of them take a `Db` in the constructor and have an `init()` method that\ncreates the collections and indexes. **Await it** — a service used without it\nbehaves like an unindexed collection.\n\n## `MongoDBSecretService`\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## 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-service-backends/references/redis.md": "# Redis (`@pikku/redis`)\n\n```bash\nyarn add @pikku/redis\n```\n\nRedis-backed implementations of Pikku's core service interfaces, using\n[ioredis](https://github.com/redis/ioredis). Every service accepts a Redis\nconnection — an ioredis `Redis` instance, `RedisOptions`, or a connection\nstring — in its constructor. None of them need an `init()` call.\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\nThere is no Redis implementation of `AgentStorageService` — AI conversation\nstorage is MongoDB-only.\n\n## `RedisSecretService`\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## 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-service-backends/references/schema.md": "# Schema validation (`@pikku/schema-ajv`, `@pikku/schema-cfworker`)\n\nTwo implementations of `SchemaService` from `@pikku/core`. Pikku uses whichever\none is wired to validate function inputs and outputs against the schemas codegen\nderives from your function definitions.\n\n```bash\nyarn add @pikku/schema-ajv # default for Node.js\nyarn add @pikku/schema-cfworker # Cloudflare Workers\n```\n\nBoth expose the same four methods:\n\n- `compileSchema(name: string, schema: any): void` — compile and register under `name`\n- `validateSchema(schemaName: string, json: any): void` — throws on failure\n- `getSchemaNames(): Set<string>`\n- `getSchemaKeys(schemaName: string): string[]` — top-level property keys, or `[]` if the schema has no `properties`\n\nOn `compileSchema` the first argument is the **name** and the second the schema —\nthe parameter is called `schema` in the source, which reads backwards.\n\n## `AjvSchemaService`\n\n```typescript\nimport { AjvSchemaService } from '@pikku/schema-ajv'\n\nconst schema = new AjvSchemaService(logger: Logger)\n```\n\nBacked by [AJV](https://ajv.js.org/).\n\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.\n- `ajv-formats` is registered, so `format` keywords (`email`, `uuid`,\n `date-time`) are enforced.\n\n## `CFWorkerSchemaService`\n\n```typescript\nimport { CFWorkerSchemaService } from '@pikku/schema-cfworker'\n\nconst schema = new CFWorkerSchemaService(logger: Logger)\n```\n\nBacked by [@cfworker/json-schema](https://github.com/cfworker/cfworker), which\nuses no `eval` or `new Function` and so runs where AJV cannot.\n\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\n## Wiring either one\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const schema = new AjvSchemaService(logger)\n return { config, logger, schema }\n})\n```\n", "pikku-service-backends/SKILL.md": "---\nname: pikku-service-backends\ndescription: >-\n Use when picking or wiring a backend for one of Pikku's core service interfaces — ContentService\n (S3, Backblaze B2), QueueService (SQS), SecretService (AWS Secrets Manager, Redis, MongoDB),\n SchemaService (AJV, cfworker), ChannelStore, EventHubStore, WorkflowService, SessionStore or\n AgentRunService (Redis, MongoDB). Covers which backend to choose, what each one silently does\n differently, and the failures they swallow. TRIGGER when: code uses S3Content, B2Content,\n SQSQueueService, AWSSecrets, RedisChannelStore, RedisSecretService, MongoDBChannelStore,\n PikkuMongoDB, AjvSchemaService or CFWorkerSchemaService, or the user asks how to store files,\n secrets, channel state or sessions. DO NOT TRIGGER when: defining service factories themselves\n (use pikku-services), SQL via Kysely (use pikku-kysely), or the Lambda/Cloudflare runtimes\n themselves (use pikku-deploy).\ninstallGroups: [core]\n---\n\n# Pikku Service Backends\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\nConstructor shapes and method signatures come from `pikku doc` — run\n`pikku doc --ai` for the installed surface. This skill is the part the compiler\ncannot tell you: which backend implements which interface, and what changes when\nyou swap one for another.\n\n`pikku-services` covers how to build and wire a service. This covers what to put\nbehind the interface.\n\n## Pick a backend\n\n| Interface | Backends | Package |\n| --- | --- | --- |\n| `ContentService` | `S3Content`, `B2Content` | `@pikku/aws-services`, `@pikku/backblaze` |\n| `QueueService` | `SQSQueueService` | `@pikku/aws-services` |\n| `SecretService` | `AWSSecrets`, `RedisSecretService`, `MongoDBSecretService` | `@pikku/aws-services`, `@pikku/redis`, `@pikku/mongodb` |\n| `SchemaService` | `AjvSchemaService`, `CFWorkerSchemaService` | `@pikku/schema-ajv`, `@pikku/schema-cfworker` |\n| `ChannelStore`, `EventHubStore` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |\n| `PikkuWorkflowService`, `WorkflowRunService` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |\n| `SessionStore`, `AgentRunService`, `DeploymentService` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |\n| `AgentStorageService`, `AgentRunStateService` | MongoDB **only** | `@pikku/mongodb` |\n\nSQL is the third option for every store interface in that table —\n`KyselyChannelStore`, `KyselyWorkflowService`, `KyselySecretService` and friends\nlive in `@pikku/kysely` and are covered by `pikku-kysely`, because using them\nmeans writing queries.\n\nPer-package detail: `references/aws.md`, `references/backblaze.md`,\n`references/redis.md`, `references/mongodb.md`, `references/schema.md`.\n\n## What changes when you swap a backend\n\n### Redis and MongoDB are not interchangeable, in two ways\n\nThey cover almost the same interface list, but:\n\n- **MongoDB has AI conversation storage and Redis does not.**\n `MongoDBAgentStorageService` is the only implementation of\n `AgentStorageService`/`AgentRunStateService`. A Redis-only deployment cannot\n persist agent conversations.\n- **Every MongoDB service needs `await init()`; no Redis service does.** `init()`\n is what creates the collections and indexes. Constructing a\n `MongoDBChannelStore` and using it without awaiting `init()` compiles and then\n behaves like an unindexed collection — slow first, wrong later.\n\nRedis services take the connection directly (an ioredis `Redis`, `RedisOptions`,\nor a URL string). MongoDB services take a `Db`, which means a `PikkuMongoDB`\nwrapper has to be constructed and initialised before any of them.\n\n### The two content backends share a design and a trap\n\n`S3Content` and `B2Content` are close enough to swap, and both:\n\n- treat `bucket` on every call as a **logical** bucket stored as a path prefix\n (`${bucket}/${key}`) inside the one real bucket the config names. Do not\n provision a bucket per logical bucket — the config takes exactly one.\n- **ignore `visibility` on `getUploadURL`**.\n- **swallow write failures**: `writeFile`, `copyFile` and `deleteFile` log and\n return `false` rather than throwing, while the read paths throw. An ignored\n return value is a silently lost file.\n\nWhere they diverge:\n\n| | `S3Content` | `B2Content` |\n| --- | --- | --- |\n| Signing failure | **Fails open** — logs and returns the *unsigned* URL | Throws |\n| `writeFile` memory | Streams | **Buffers the whole stream** to compute a SHA-1 |\n| Client-side upload integrity | Presigned, expires at a fixed 3600s | `X-Bz-Content-Sha1: do_not_verify` — unverified |\n| Credential rotation | Picked up by the SDK provider chain | Auth is cached for the instance's lifetime — construct a new `B2Content` |\n\nThe S3 fail-open is the one to design around: on a private CloudFront\ndistribution the client gets a 403, and on a public one you have just handed out\nan unrestricted link. Validate `signConfig` at boot rather than trusting a throw.\n\n### Secret backends differ on whether the app can write\n\n- **`AWSSecrets` is read-only.** `setSecret` and `deleteSecret` throw. Secrets\n are managed out of band; the app only reads them.\n- **Redis and MongoDB do envelope encryption** and can write, delete, and\n `rotateKEK()`. Rotation requires `previousKey` to have been set — a service\n constructed without it cannot rotate later without a redeploy.\n- **Only MongoDB has audit hooks** (`audit`, `auditReads`).\n\n`AWSSecrets` also collapses every failure — missing, denied, binary-only — into\nthe same `FATAL: Error finding secret: <id>`, with the real reason on the error's\n`cause`. Read `cause` before concluding a secret is absent; `hasSecret` returns\n`false` for any error and cannot distinguish the two either.\n\n### AJV and cfworker are not drop-in equivalents\n\nSwapping them changes behaviour without changing types:\n\n- **`useDefaults`**: AJV fills schema defaults into the validated object in\n place. cfworker does not, so a field you relied on being defaulted arrives\n `undefined` on Workers.\n- **Recompilation**: AJV caches by name for the process lifetime — a second\n `compileSchema` with the same name is a no-op. cfworker replaces the validator\n when the schema value changes, which is what lets a dev hot-reload pick up\n regenerated schemas. On AJV, restart the process instead.\n- **Coercion is neither one's job.** `coerceTypes` is off; a query-string `\"1\"`\n becomes `1` in the wiring layer, not here.\n\nBoth throw `UnprocessableContentError` (422) on a failed validation, and both\nthrow a **bare string** — `Missing validator for <name>` — for a *missing*\nschema. It is not an `Error`, so `catch (e) { e.message }` reads `undefined`.\nThat almost always means codegen did not run.\n\nUse cfworker on Cloudflare Workers: AJV compiles with `new Function`, which the\nWorkers runtime forbids.\n\n### SQS gives you no result back\n\n`SQSQueueService` sets `supportsResults = false` and `getJob()` always throws —\nthe transport is fire-and-forget. So is the Azure Storage Queue backend. Reach\nfor BullMQ or PgBoss (see `pikku-wiring`) when a caller needs the job's result.\n\n## What NOT to do\n\n- Do not ignore the boolean from `writeFile`, `copyFile` or `deleteFile`. Both\n content backends report failure that way and neither throws.\n- Do not rely on `S3Content.signURL` throwing. It fails open and hands back an\n unsigned URL.\n- Do not construct a MongoDB-backed service without awaiting `init()`.\n- Do not assume AJV and cfworker validate identically — `useDefaults` alone\n changes what your function receives.\n- Do not provision one real bucket per logical bucket; the prefix is the bucket.\n- Do not reach for SQS when a caller needs the result of the job.\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(\n 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```\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({\n type: 'user.deleted',\n source: 'explicit',\n metadata: { userId },\n })\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/references/audit.md": "# Pikku Audit\n\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(\n 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\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\n .selectFrom('invoice') /* ... */\n .executeTakeFirstOrThrow()\n await kysely\n .updateTable('invoice')\n .set({ status: 'cancelled' }) /* ... */\n .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 {\n auditLog,\n kysely: createAuditedKysely(services.kysely, { audit: auditLog }),\n }\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?\n wireType?\n wireId?\n traceId?\n transactionId?\n 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-services/references/config.md": "# Pikku Config, Secrets & OAuth2\n\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(\n (await secrets.getSecret('STRIPE_CONFIG')).reveal()\n ),\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 `references/services.md`).\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-services/references/pino.md": "# Pikku Pino (Structured Logging)\n\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-services/references/services.md": "# Pikku Services (Dependency Injection)\n\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/setup'\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/setup'\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 &\n SingletonServices\nexport type WiredServices = SecretlessServices<\n RequiredSingletonServices & Services\n>\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/setup'\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) {\n return this.todos.get(id)\n }\n async list() {\n return [...this.todos.values()]\n }\n async delete(id: string) {\n this.todos.delete(id)\n }\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-services/SKILL.md": "---\nname: pikku-services\ndescription: >-\n Use for the service layer of a Pikku app — dependency injection with pikkuServices and\n pikkuWireServices, startup/shutdown work with pikkuServerLifecycle, configuration through the\n secrets, variables and credentials services, the audit sink and buffer, and structured logging.\n TRIGGER when: code uses pikkuServices/pikkuWireServices/pikkuServerLifecycle, defineSecret,\n defineVariable or defineCredential, user asks about services.ts, lifecycle.ts, dependency\n injection, env vars, secrets, API credentials, audit logging, AuditService, PinoLogger, or a\n built-in service. DO NOT TRIGGER when: user asks about middleware (use pikku-middleware),\n authentication or permissions (use pikku-auth), or a third-party backend such as Redis, S3 or\n MongoDB (use pikku-service-backends).\ninstallGroups: [core]\n---\n\n# Pikku Services\n\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: where a\nvalue is allowed to be read, and what a service's lifetime commits you to.\n\n## Two lifetimes, and that is the whole model\n\n**Singleton services** (`pikkuServices`) are built once at startup and live for\nthe process. **Wire services** (`pikkuWireServices`) are built fresh per HTTP\nrequest, queue job, channel message or CLI command, and receive the wire.\n\nEverything else follows from that split: a database pool is a singleton, a\nrequest-scoped logger or audit buffer is a wire service, and startup work that\nneeds the singletons goes in `pikkuServerLifecycle` rather than in a module's\ntop level.\n\n## Pick the reference\n\n| You are… | Read |\n| ---------------------------------------------------------------------------- | ------------------------------------------------------------ |\n| Writing or wiring `services.ts` / `lifecycle.ts`, or adding a custom service | `references/services.md` |\n| Reading config — secrets, env vars, or a per-user API credential | `references/config.md` |\n| Recording audit events, or choosing a sink | `references/audit.md` and `references/audit-wire-service.md` |\n| Setting up structured logging | `references/pino.md` |\n| Reaching for Redis, S3, SQS, MongoDB or a schema backend | `pikku-service-backends` |\n| Sending outgoing webhooks to a customer's endpoint | `pikku-webhook` |\n\n## Where a value may be read\n\n- **Never `process.env` inside a Pikku function.** Use `services.variables.get()`\n or `services.secrets`. `process.env` belongs to server bootstrap (`start.ts`)\n — and under `pikku dev` / `pikku serve` there is no `start.ts` at all, so\n startup work goes in a `pikkuServerLifecycle` hook, which receives the\n singletons and reads through `variables` too.\n- **`secrets` never reaches a function.** `WiredServices` is wrapped in\n `SecretlessServices<…>`, so it is stripped at the type level rather than merely\n omitted by convention. Read a secret in a service factory or middleware and\n hand the resulting service the value.\n\n## What NOT to do\n\n- **Do not guard a service's existence in a function body.** `if (!db) throw …`\n is dead code. Optionality lives only in the `SingletonServices` declaration and\n means \"may not be created\" — a service is optional precisely because nothing\n destructures it. The inspector records every service destructured by a wired\n `func`, `permissions` or `middleware` and marks it required, so inside the\n function it is a non-optional value. A genuinely conditional integration is a\n configuration concern: branch in `services.ts` or fail fast at startup.\n- **Do not take `services` as a named parameter and cast in the body.** The\n inspector reads the destructuring pattern to build the manifest; a cast makes\n the service invisible to it, so tree-shaking drops what the function needs.\n- **Do not hand-roll an audit table.** `auditLog.write()` on an `audit: true`\n function enriches the event with the function id, wire type, trace id and user\n identity; an `insertInto('audit_log')` of your own gets none of that, and a\n write inside a transaction that later rolls back records nothing.\n- **Do not log an unrevealed secret.** Every logger argument is `Safe<>`-guarded,\n so a `SecretValue` nested anywhere in the call collapses it to `never` and it\n stops compiling. That is deliberate — it would have printed `[secret]` anyway.\n", "pikku-software-archaeology/example/second-opinion-sample-report.md": "# Your app, in plain English — and where it could get better\n\n_A second opinion on the competitor-tracking system_\n\n> Worked example for the pikku-software-archaeology 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\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\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-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│ ├── second-opinion.md # blueprint → owner-facing report: method, voice, red flags\n│ └── second-opinion-report-template.md # the layered structure to fill in\n├── example/\n│ └── second-opinion-sample-report.md # worked example (competitor-tracking area, founder voice)\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\": {\n \"type\": \"string\",\n \"description\": \"what this location shows\"\n }\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\": [\n \"name\",\n \"purpose\",\n \"actors\",\n \"capabilities\",\n \"terminology\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"purpose\": {\n \"type\": \"string\",\n \"description\": \"1-3 sentences: what problem, for whom\"\n },\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\": {\n \"type\": \"string\",\n \"enum\": [\"human\", \"system\", \"external\"]\n },\n \"description\": { \"type\": \"string\" }\n }\n }\n },\n \"capabilities\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" }\n },\n \"terminology\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"term\", \"meaning\"],\n \"properties\": {\n \"term\": { \"type\": \"string\" },\n \"meaning\": { \"type\": \"string\" }\n }\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\": [\n \"name\",\n \"description\",\n \"entities\",\n \"commands\",\n \"queries\",\n \"events\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"entities\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"commands\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"queries\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"events\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"policies\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": \"policy names from policies.json owned by this domain\"\n },\n \"sourcePaths\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"where this domain currently lives (usually scattered)\"\n },\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\": [\n \"name\",\n \"domain\",\n \"description\",\n \"attributes\",\n \"evidence\",\n \"confidence\"\n ],\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"belongs-to\",\n \"has-many\",\n \"has-one\",\n \"references\",\n \"many-to-many\"\n ]\n },\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\": {\n \"type\": \"string\",\n \"description\": \"command or event name that causes it\"\n }\n }\n }\n },\n \"ownership\": {\n \"type\": \"string\",\n \"description\": \"which actor/tenant owns rows of this entity\"\n },\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\": [\n \"name\",\n \"domain\",\n \"actor\",\n \"input\",\n \"preconditions\",\n \"effects\",\n \"eventsProduced\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/$defs/conceptName\",\n \"description\": \"imperative VerbNoun: ApproveInvoice\"\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": {\n \"type\": \"string\",\n \"description\": \"how it is invoked today (route, job, webhook, cron)\"\n },\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\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"effects\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" }\n },\n \"eventsProduced\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"policies\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"policy names from policies.json enforced here\"\n },\n \"currentImplementation\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"file paths implementing it today\"\n },\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\": [\n \"name\",\n \"domain\",\n \"actor\",\n \"description\",\n \"returns\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/$defs/conceptName\",\n \"description\": \"GetX / ListX / SearchX\"\n },\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\": {\n \"name\": { \"type\": \"string\" },\n \"type\": { \"type\": \"string\" },\n \"required\": { \"type\": \"boolean\" }\n }\n }\n },\n \"returns\": { \"type\": \"string\" },\n \"scoping\": {\n \"type\": \"string\",\n \"description\": \"tenancy/visibility rule applied (e.g. 'rows owned by caller only')\"\n },\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\": [\n \"name\",\n \"domain\",\n \"description\",\n \"producedBy\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/$defs/conceptName\",\n \"description\": \"past-tense business fact: InvoicePaid. No technical events (ButtonClicked).\"\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"producedBy\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" },\n \"description\": \"command/workflow names\"\n },\n \"consumedBy\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"what reacts today (email send, status flip, webhook out)\"\n },\n \"payloadHints\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"explicit\": {\n \"type\": \"boolean\",\n \"description\": \"true if the code emits a real event; false if reconstructed from inline side-effects\"\n },\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\": [\n \"name\",\n \"rule\",\n \"type\",\n \"domain\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"rule\": {\n \"type\": \"string\",\n \"description\": \"plain-language statement: 'Only the invoice owner can send it'\"\n },\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"authorization\",\n \"validation\",\n \"state\",\n \"business-constraint\",\n \"rate-limit\"\n ]\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedAt\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"every code location enforcing it — multiple locations = divergence risk, note in gaps.json\"\n },\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\": [\n \"name\",\n \"kind\",\n \"actor\",\n \"trigger\",\n \"steps\",\n \"result\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"user\", \"admin\", \"system\"] },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\" },\n \"steps\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" }\n },\n \"result\": { \"type\": \"string\" },\n \"commandsInvolved\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"eventsInvolved\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"schedule\": {\n \"type\": \"string\",\n \"description\": \"cron expression for system workflows, if any\"\n },\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\": {\n \"type\": \"string\",\n \"description\": \"test file path + test name, when sourced from a test\"\n }\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"rest\",\n \"graphql\",\n \"rpc\",\n \"webhook-in\",\n \"webhook-out\",\n \"page\",\n \"cli\",\n \"websocket\",\n \"sse\"\n ]\n },\n \"method\": { \"type\": \"string\" },\n \"path\": { \"type\": \"string\" },\n \"auth\": {\n \"type\": \"string\",\n \"description\": \"none | session | jwt | api-key | signature | capability-url | ...\"\n },\n \"mapsTo\": {\n \"type\": \"object\",\n \"required\": [\"type\", \"name\"],\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\"command\", \"query\", \"event-ingress\"],\n \"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 },\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\": [\n \"name\",\n \"category\",\n \"purpose\",\n \"direction\",\n \"importance\",\n \"replacementDifficulty\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"category\": {\n \"type\": \"string\",\n \"description\": \"payments | email | auth | storage | analytics | llm | sms | ...\"\n },\n \"purpose\": { \"type\": \"string\" },\n \"dataExchanged\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"direction\": {\n \"type\": \"string\",\n \"enum\": [\"outbound\", \"inbound\", \"both\"]\n },\n \"importance\": {\n \"type\": \"string\",\n \"enum\": [\"critical\", \"important\", \"peripheral\"]\n },\n \"replacementDifficulty\": {\n \"type\": \"string\",\n \"enum\": [\"trivial\", \"moderate\", \"hard\"]\n },\n \"configVia\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"env vars / secrets used\"\n },\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"frontend\",\n \"api\",\n \"worker\",\n \"job\",\n \"webhook-handler\",\n \"cli\",\n \"service\",\n \"proxy\",\n \"other\"\n ]\n },\n \"responsibility\": { \"type\": \"string\" },\n \"dependsOn\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"runtime\": {\n \"type\": \"string\",\n \"description\": \"how it runs today (process, cron, lambda, ...)\"\n },\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\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"deployment constraints worth preserving (ports, raw-body routes, ordering)\"\n }\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\": [\n \"statement\",\n \"domain\",\n \"enforcedBy\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"statement\": {\n \"type\": \"string\",\n \"description\": \"must ALWAYS hold: 'Invoice numbers are sequential per user with no gaps'\"\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedBy\": {\n \"type\": \"string\",\n \"description\": \"db-constraint | code-guard | convention | nothing (!)\"\n },\n \"atRiskBecause\": {\n \"type\": \"string\",\n \"description\": \"why current enforcement is fragile, if it is\"\n },\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\": [\n \"problem\",\n \"kind\",\n \"impact\",\n \"recommendation\",\n \"evidence\"\n ],\n \"properties\": {\n \"problem\": { \"type\": \"string\" },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"incomplete-feature\",\n \"todo\",\n \"duplication\",\n \"bug\",\n \"hack\",\n \"dead-code\",\n \"unclear-ownership\",\n \"architecture\",\n \"security\",\n \"open-product-decision\"\n ]\n },\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\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" },\n \"description\": \"file paths in the existing repo\"\n },\n \"future\": {\n \"type\": \"object\",\n \"required\": [\"domain\"],\n \"properties\": {\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"concepts\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"commands/queries/events/entities this code becomes\"\n }\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\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"file path; for sub-file drops use 'path (symbolName)' when part of a file survives\"\n },\n \"reason\": { \"type\": \"string\" }\n }\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\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n }\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\": [\n \"name\",\n \"kind\",\n \"audience\",\n \"purpose\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"web-ui\",\n \"cli\",\n \"mcp\",\n \"openapi-rest\",\n \"graphql\",\n \"sdk\",\n \"websocket-realtime\",\n \"webhook-in\",\n \"webhook-out\",\n \"email\",\n \"other\"\n ]\n },\n \"audience\": {\n \"type\": \"string\",\n \"enum\": [\n \"human\",\n \"developer\",\n \"agent\",\n \"external-system\",\n \"internal\"\n ]\n },\n \"purpose\": { \"type\": \"string\" },\n \"surfaceCount\": {\n \"type\": \"number\",\n \"description\": \"how many ops/tools/commands/routes this channel exposes\"\n },\n \"generated\": {\n \"type\": \"boolean\",\n \"description\": \"true if generated from another source (OpenAPI from routes, typed SDK, MCP tools from funcs) rather than hand-written\"\n },\n \"domainsServed\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"complete\", \"partial\", \"stub\", \"deprecated\"]\n },\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\": [\n \"framework\",\n \"styling\",\n \"dataLayer\",\n \"auth\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"framework\": {\n \"type\": \"string\",\n \"description\": \"e.g. TanStack Start, Next.js, Remix, Vite SPA\"\n },\n \"rendering\": {\n \"type\": \"string\",\n \"enum\": [\"spa\", \"ssr\", \"ssg\", \"streaming-ssr\", \"mixed\"]\n },\n \"router\": {\n \"type\": \"string\",\n \"description\": \"e.g. TanStack Router (file-based), React Router\"\n },\n \"styling\": {\n \"type\": \"string\",\n \"description\": \"the design system, e.g. Mantine, Tailwind, MUI, CSS modules\"\n },\n \"designSystemConsistency\": {\n \"type\": \"string\",\n \"enum\": [\"single-system\", \"mostly-consistent\", \"mixed\", \"ad-hoc\"],\n \"description\": \"how uniformly ONE component/theme system is used — the rebuild target is 'everything Mantine', so divergence is porting work\"\n },\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\": [\n \"pattern\",\n \"observation\",\n \"recommendation\",\n \"evidence\"\n ],\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\": {\n \"type\": \"string\",\n \"description\": \"what is inconsistent, with concrete examples (e.g. 'Add-site uses a Drawer, Edit-site uses a Modal for the same task')\"\n },\n \"impact\": {\n \"type\": \"string\",\n \"description\": \"what it does to the user (feels unpolished/confusing) or to maintenance (a color change means hunting every file)\"\n },\n \"recommendation\": {\n \"type\": \"string\",\n \"description\": \"the fix — usually standardize on one pattern, move values to theme tokens, or extract one shared component\"\n },\n \"severity\": {\n \"type\": \"string\",\n \"enum\": [\"minor\", \"worth-fixing\", \"serious\"]\n },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n },\n \"stateManagement\": { \"type\": \"string\" },\n \"dataLayer\": {\n \"type\": \"string\",\n \"description\": \"how the UI talks to the backend: e.g. pikku-react hooks, REST fetch helpers, tRPC, GraphQL client\"\n },\n \"auth\": {\n \"type\": \"string\",\n \"description\": \"client auth mechanism: e.g. better-auth, next-auth, custom JWT\"\n },\n \"buildTool\": { \"type\": \"string\" },\n \"i18n\": {\n \"type\": \"string\",\n \"description\": \"internationalization approach, or 'none'\"\n },\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\": {\n \"type\": \"string\",\n \"description\": \"URL path, e.g. /app/sites/:id\"\n },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"page\",\n \"layout\",\n \"index\",\n \"modal-or-drawer\",\n \"redirect\"\n ]\n },\n \"purpose\": {\n \"type\": \"string\",\n \"description\": \"what the user does/sees here, in product terms\"\n },\n \"auth\": {\n \"type\": \"string\",\n \"description\": \"none | authenticated | admin | role:xyz\"\n },\n \"dataFrom\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"query/command names (from queries.json/commands.json) this route reads/calls\"\n },\n \"usesComponents\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"component names from frontend-components.json\"\n },\n \"userFlows\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"workflows.json (kind=user) names this route participates in\"\n },\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"layout\",\n \"navigation\",\n \"presentational\",\n \"feature\",\n \"form\",\n \"data-display\",\n \"chart\",\n \"table\",\n \"overlay\",\n \"provider\",\n \"other\"\n ]\n },\n \"purpose\": { \"type\": \"string\" },\n \"reuse\": {\n \"type\": \"string\",\n \"enum\": [\"shared\", \"one-off\"],\n \"description\": \"used across features vs single-use\"\n },\n \"rebuild\": {\n \"type\": \"string\",\n \"enum\": [\n \"mantine-standard\",\n \"mantine-composition\",\n \"custom-style\",\n \"custom-logic\"\n ],\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\": {\n \"type\": \"string\",\n \"description\": \"REQUIRED when rebuild=custom-logic: what the bespoke behavior is and why a stock component can't replace it\"\n },\n \"dependencies\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"notable libs it pulls in (charting/table/editor) — replacement considerations for the port\"\n },\n \"usedBy\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"routes/components that use it\"\n },\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-build`) 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-wiring` EventHub topics / channels |\n| `frontend.json` | `apps/app` (TanStack Start) shell: router, `@pikku/mantine` theme, `pikku-react` 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/references/second-opinion-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\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\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\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-software-archaeology/references/second-opinion.md": "# 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 the extraction phase (see SKILL.md). If none exists, run that 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\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/second-opinion-sample-report.md`):\n\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\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\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\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\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\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\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\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**:\n\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 the extraction phase\n\nExtraction = facts → `.knowledge/` blueprint, for a machine to rebuild from. This report = blueprint → opinionated argument, for a human to decide from. One extracts; one advises. Run the extraction first (or point this at an existing `.knowledge/`), then translate its `gaps.json` + `invariants.json` + `migration.json` into the business-language report above.\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(\n readFileSync(join(here, '..', 'references', 'blueprint.schema.json'), 'utf8')\n)\n\nconst dir = process.argv[2]\nif (!dir) {\n console.error('usage: node validate.mjs <.knowledge dir>')\n process.exit(2)\n}\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)\n schema = { ...resolveRef(schema.$ref), ...schema, $ref: undefined }\n if (schema.enum && !schema.enum.includes(value)) {\n errors.push(\n `${path}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}`\n )\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`)\n return\n }\n for (const req of schema.required || []) {\n if (!(req in value))\n 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)) {\n errors.push(`${path}: expected array`)\n return\n }\n if (schema.minItems && value.length < schema.minItems) {\n errors.push(\n `${path}: needs at least ${schema.minItems} item(s), has ${value.length}`\n )\n }\n if (schema.items)\n value.forEach((v, i) => check(v, schema.items, `${path}[${i}]`))\n } else if (t === 'string') {\n if (typeof value !== 'string') {\n errors.push(`${path}: expected string`)\n return\n }\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})`)\n 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(\n (docs['domains.json'].domains || []).map((d) => d.name)\n )\n const commandNames = new Set(\n (docs['commands.json'].commands || []).map((c) => c.name)\n )\n const queryNames = new Set(\n (docs['queries.json']?.queries || []).map((q) => q.name)\n )\n const eventNames = new Set(\n (docs['events.json']?.events || []).map((e) => e.name)\n )\n\n const wantDomain = (owner, d) => {\n if (d && !domains.has(d))\n 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))\n warnings.push(\n `commands.json:${c.name} produces \"${ev}\" which is not in events.json`\n )\n }\n }\n for (const q of docs['queries.json']?.queries || [])\n wantDomain(`queries.json:${q.name}`, q.domain)\n for (const e of docs['entities.json']?.entities || [])\n wantDomain(`entities.json:${e.name}`, e.domain)\n for (const ev of docs['events.json']?.events || [])\n 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))\n errors.push(\n `api.json:${s.method || ''} ${s.path}: maps to unknown command \"${name}\"`\n )\n if (type === 'query' && !queryNames.has(name))\n errors.push(\n `api.json:${s.method || ''} ${s.path}: maps to unknown query \"${name}\"`\n )\n if (type === 'event-ingress' && !eventNames.has(name))\n errors.push(\n `api.json:${s.method || ''} ${s.path}: event-ingress maps to unknown event \"${name}\" (state-changing webhooks should map to a command instead)`\n )\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 || [])\n if (!commandNames.has(c))\n warnings.push(\n `domains.json:${d.name}: lists command \"${c}\" not in commands.json`\n )\n for (const q of d.queries || [])\n if (!queryNames.has(q))\n warnings.push(\n `domains.json:${d.name}: lists query \"${q}\" not in queries.json`\n )\n for (const e of d.events || [])\n if (!eventNames.has(e))\n warnings.push(\n `domains.json:${d.name}: lists event \"${e}\" not in events.json`\n )\n const policyNames = new Set(\n (docs['policies.json']?.policies || []).map((p) => p.name)\n )\n for (const p of d.policies || [])\n if (!policyNames.has(p))\n warnings.push(\n `domains.json:${d.name}: lists policy \"${p}\" not in policies.json`\n )\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(\n `commands.json:${c.name}: no policies or preconditions — really unguarded, or missed extraction?`\n )\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(\n `frontend-routes.json:${r.path}: uses component \"${c}\" not in frontend-components.json`\n )\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(\n `frontend-components.json:${c.name}: rebuild=custom-logic but no customLogic description — port risk is unactionable`\n )\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(\n `frontend-routes.json:${r.path}: reads \"${d}\" which is not a known query/command`\n )\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 (\n (consistency === 'mixed' || consistency === 'ad-hoc') &&\n findingCount === 0\n ) {\n warnings.push(\n `frontend.json: designSystemConsistency=\"${consistency}\" but designFindings is empty — name the specific broken patterns (interaction/theming/cross-page/…)`\n )\n }\n}\n\nfor (const w of warnings) console.log(`WARN ${w}`)\nfor (const e of errors) console.log(`ERROR ${e}`)\nconsole.log(\n `\\n${errors.length} error(s), ${warnings.length} warning(s) across ${Object.keys(docs).length} files`\n)\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) — and when turning that blueprint into a plain-language second opinion for the non-technical owner who holds the 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\", 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, or asks \"explain how my app works\" / \"what would you do differently\" / \"is this built well?\" for a founder, PM or operator audience. DO NOT TRIGGER for: documenting code structure, generating API docs from an already-clean codebase, or an engineer-facing code review.'\ninstallGroups: [core]\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\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\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\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\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 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\n## The second phase — a report the owner can act on\n\nExtraction produces a blueprint for a machine to rebuild from. The same\n`.knowledge/` directory is also the input to a second opinion for the person who\nholds the app: what works, what is holding them back, and what you would build\ninstead, argued in business outcomes rather than architecture.\n\nThat phase has its own voice rules, structure and red flags — read\n`references/second-opinion.md`, fill in\n`references/second-opinion-report-template.md`, and match the tone of\n`example/second-opinion-sample-report.md`. It consumes the blueprint; it never\nre-derives facts from the code, so run the extraction first.\n", "pikku-webhook/SKILL.md": "---\nname: pikku-webhook\ndescription: >-\n Use when an application needs to SEND outgoing webhooks — notifying a customer's endpoint that\n something happened, with signing, retries and a delivery log. Covers WebhookService,\n QueueWebhookService, the `pikku-outgoing-webhooks` queue worker, `scaffold.webhook`,\n `config.webhook`, signature verification on the receiving side, and KyselyWebhookService's\n delivery history. TRIGGER when: code uses webhookService, QueueWebhookService,\n KyselyWebhookService, pikkuWebhookWorkerFunc, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,\n SendWebhookInput or X-Pikku-Signature. TRIGGER when: the user asks to emit events to a\n customer URL, build a webhook endpoint settings screen, add a signing secret, or show\n delivery attempts. DO NOT TRIGGER when: the user is RECEIVING webhooks from a third party\n into a route — that is an ordinary wireHTTP function (use pikku-wiring).\ninstallGroups: [core]\n---\n\n# Pikku Outgoing Webhooks\n\nPikku ships an outgoing webhook primitive, so an application never hand-rolls\n`fetch` + HMAC + retries. `WebhookService.send()` signs the body, enqueues a\ndelivery job, and the generated `pikku-outgoing-webhooks` queue worker POSTs it;\na non-2xx throws, so the queue retries with backoff. Swapping the queue-only\ndefault for `KyselyWebhookService` adds a durable delivery + attempt history\nwith no change to call sites.\n\n**Do not write a bespoke `fetch(url, { headers: { 'x-my-signature': … } })` for\nan outgoing event.** If the shipped signing scheme or delivery model genuinely\ndoes not fit, subclass `WebhookService` — it is an abstract class precisely so\nan app can substitute its own transport (direct send, Svix) and keep the same\ncall sites and delivery history.\n\n## Agent Operating Procedure\n\n1. Turn the feature on: `\"scaffold\": { \"webhook\": true }` in `pikku.config.json`.\n2. Run `pikku all`. It writes `<scaffold.pikkuDir>/webhook/webhook.gen.ts` (the\n queue worker) and `webhook.schemas.gen.ts`. Never hand-edit either.\n3. Register a `webhookService` singleton in `createSingletonServices`. Without\n it `services.webhookService` is `undefined` and nothing sends.\n4. Make sure a queue backend is wired. The worker is an ordinary\n `wireQueueWorker` — with no `queueService`, `send()` throws.\n5. Call `webhookService.send(...)` from a function body. Never call `fetch`\n directly for an outgoing event.\n6. Validate with `pikku all` and the project's typecheck.\n\n## Config\n\n```jsonc\n// pikku.config.json\n{\n \"scaffold\": {\n \"pikkuDir\": \"src/pikku\",\n \"webhook\": true, // on or off — it exposes no endpoint and has no path override\n },\n}\n```\n\n`scaffold.webhook` is a plain boolean, unlike the other scaffold flags which\naccept `{ path }`. The two generated paths are derived\n(`webhookWorkersFile`, `webhookSchemasFile`) and can be set explicitly in\n`pikku.config.json` if a project needs them somewhere else.\n\nRuntime defaults live on `CoreConfig.webhook`:\n\n```ts\nexport interface Config extends CoreConfig {}\n\nconst config: Config = {\n webhook: {\n secret: 'WEBHOOK_SIGNING_KEY', // a secret NAME, resolved via services.secrets\n signatureHeader: 'X-Pikku-Signature', // default\n retries: 3, // default; attempts = retries + 1\n retryDelay: '30s', // omit for exponential backoff\n allowedHosts: ['hooks.example.com'], // SSRF allowlist\n },\n}\n```\n\n`allowedHosts` set means _only_ those hostnames are deliverable. Omitted, every\npublic host is allowed and private/internal ranges are blocked — loopback,\nRFC1918, link-local `169.254.0.0/16` (cloud metadata), CGNAT `100.64.0.0/10`\n(Alibaba's metadata endpoint), multicast and the TEST-NETs. A URL is\nuser-supplied data; do not bypass `safeFetch` by delivering yourself.\n\n## Sending\n\n```ts\nimport { pikkuFunc } from '#pikku/function'\n\nexport const notifyOrderShipped = pikkuFunc({\n func: async ({ webhookService }, { endpointUrl, orderId, secret }) => {\n const { jobId } = await webhookService.send({\n url: endpointUrl,\n event: 'order.shipped',\n data: { orderId, shippedAt: new Date().toISOString() },\n secret, // per-endpoint raw key; overrides config.webhook.secret\n organizationId: orgId, // persisted by store-backed services only\n })\n return { jobId }\n },\n})\n```\n\n`send()` returns as soon as the job is enqueued — it is **not** a delivery\nreceipt. `jobId` is the queue job; with `KyselyWebhookService` it is also the\n`deliveryId`, so it is stable across retries and is what a UI polls.\n\nThe two `secret` fields are deliberately different and are the most common\nmistake:\n\n| Where | Meaning |\n| ------------------------- | ------------------------------------------------------------------- |\n| `config.webhook.secret` | a secret **name**, read through `services.secrets` at enqueue time |\n| `SendWebhookInput.secret` | a **raw HMAC key**, for per-endpoint secrets held in your own table |\n\nThe raw key never enters the queue payload: the body is signed at enqueue time\nand only the resulting header travels with the job. That is also why the body\nis serialized once — a retry re-POSTs identical bytes, so the signature stays\nvalid.\n\nWith neither secret set, deliveries go **unsigned**. A missing named secret is\nlogged as an error and still sends unsigned; treat that log line as a\nmisconfiguration, not noise.\n\n## What this does not give you\n\nThere is no subscription model. `webhookSchema` owns exactly two tables —\n`webhookDelivery` and `webhookDeliveryAttempt` — and both are delivery-side\nhistory. Nothing stores _which_ URL belongs to which customer, which events\nthey asked for, or whether their endpoint is still enabled.\n\nThat is the app's table, and every app that exposes webhooks to its users\nneeds one:\n\n| Column | Why |\n| --------- | ------------------------------------------------------------------------------------------------------------- |\n| `url` | where to POST |\n| `secret` | the raw HMAC key, passed as `SendWebhookInput.secret` |\n| `events` | which event names this endpoint subscribed to |\n| `enabled` | so a failing endpoint can be paused without deleting it |\n| scope | the org/tenant column you filter on — mirror it into `organizationId` so the delivery log scopes the same way |\n\nSo one emitted event becomes a `SELECT` over your endpoint table and one\n`send()` per row. Everything after that call — signing, queueing, retrying,\nrecording — is the primitive's.\n\nThe single-integration case needs none of this: one fixed URL on the row it\nbelongs to, and `send()` straight at it.\n\n## Verifying on the receiving side\n\n`sign()` produces `sha256=<hex>` (GitHub style, body only, no timestamp) into\n`X-Pikku-Signature`, and `X-Pikku-Event` carries the event name. `verify()` is\npublic because receivers share the scheme:\n\n```ts\nconst raw = await request.text()\nif (\n !webhookService.verify(secret, request.headers.get('x-pikku-signature')!, raw)\n) {\n throw new UnauthorizedError()\n}\n```\n\nIt compares in constant time via `timingSafeStringEqual`. Never compare\nsignatures with `===`, and verify against the **raw body text**, not a\nre-serialized parsed object.\n\n## Delivery history\n\nThe default `QueueWebhookService` keeps no history: `listDeliveries`,\n`getDelivery` and `recordAttempt` throw `NotImplementedError`. Register\n`KyselyWebhookService` from `@pikku/kysely` to get them.\n\n```ts\nimport { KyselyWebhookService } from '@pikku/kysely'\n\nconst webhookService = new KyselyWebhookService(queueService, kysely)\nawait webhookService.init() // creates webhookDelivery + webhookDeliveryAttempt\n```\n\n`init()` is idempotent and creates the tables through the pikku schema\nbootstrap. Do not write your own migration for these tables.\n\n- `webhookDelivery` — one row per `send()`: `deliveryId`, `organizationId`,\n `url`, `event`, `status` (`pending` | `delivered` | `failed`), `attempts`,\n `createdAt`, `updatedAt`, `deliveredAt`.\n- `webhookDeliveryAttempt` — one row per try: `attemptNumber`, `statusCode`,\n `responseBody` (failures only, truncated to 2000 chars), `error`.\n\nRead them through the service, not with your own query:\n\n```ts\nconst deliveries = await webhookService.listDeliveries({\n organizationId,\n limit: 25,\n})\nconst detail = await webhookService.getDelivery(deliveryId) // { delivery, attempts }\n```\n\nBuilding a console screen is `listDeliveries` for the list and `getDelivery` for\nthe drill-in. A hand-written select over `webhookDelivery` is a sign the wrong\nservice is registered.\n\n## What the worker does\n\nThe generated worker is a thin wrapper over `pikkuWebhookWorkerFunc`. It POSTs\nthrough `safeFetch` with a 30s timeout, treats 2xx as delivered, captures the\nresponse body on failure, records the attempt when a `deliveryId` is present,\nand **throws** on failure so the queue retries. Attempt recording is\nbest-effort: a store error is logged and does not fail the delivery. Retry\nexhaustion is logged by the queue runner — there is no `onFailure` hook.\n\n## Gotchas\n\n- `webhookService` is optional on `CoreSingletonServices`, but do **not** guard\n it in a function body — destructuring it marks it required (see\n `pikku-services`). Register it in `services.ts` or fail fast at startup.\n- The queue name is `pikku-outgoing-webhooks`, not `pikku-webhooks`.\n- `retries: 0` means one attempt and no backoff, not \"retry forever\".\n- The gateway's inbound `webhook` transport type is a different feature. This\n skill is outbound only.\n- Signing is body-only with no timestamp, so it does not defend against replay\n on its own. If a receiver needs replay protection, put a nonce or timestamp\n **inside** the payload, where it is covered by the signature.\n", "pikku-wiring/references/channel.md": "# Pikku WebSocket Wiring\n\n## API Reference\n\n### `wireChannel(config)`\n\n```typescript\nimport { wireChannel } from '#pikku/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-auth`).\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: {\n // ← 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-wiring/references/cli-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/cli'\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-wiring/references/cli.md": "# Pikku CLI Wiring\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/cli'\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/cli'\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/cli'\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 `cli-complete-example.md`.\n", "pikku-wiring/references/gateway-slack.md": "# Pikku Gateway Slack\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-wiring/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-wiring/references/http.md": "# Pikku HTTP Wiring\n\n## API Reference\n\nAll three come from `#pikku/http` (the generated `.pikku/http/index.ts`), 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 `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-auth`), 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-auth`), 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/function'\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/middleware'\nimport { cors, authBearer } from '@pikku/core/middleware'\n\naddHTTPMiddleware('*', [cors(), authBearer()])\n```\n", "pikku-wiring/references/mcp.md": "# Pikku MCP Wiring\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/mcp'\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/mcp'\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/mcp'\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/mcp'\n\nexport const planDayPrompt = pikkuMCPPromptFunc({\n input: UserIdInputSchema,\n func: async (_services, { userId }, { rpc }) => {\n const { todos } = await rpc.invoke('listTodos', {\n userId,\n completed: false,\n })\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-wiring/references/queue.md": "# Pikku Queue Wiring\n\n## API Reference\n\n### `wireQueueWorker(config)`\n\n```typescript\nimport { wireQueueWorker } from '#pikku/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-wiring/references/realtime-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-wiring/references/realtime.md": "# Pikku Realtime\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\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?: {\n reconnect?: boolean\n reconnectDelayMs?: number\n reconnectMaxDelayMs?: number\n })\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>(\n topic: K,\n handler: (data: EventHubTopics[K]) => void\n ): () => void\n unsubscribe<K extends keyof EventHubTopics>(\n topic: K,\n handler?: (data: EventHubTopics[K]) => void\n ): void\n\n // SSE at GET /events/:topic — one EventSource per topic\n subscribeToTopic<K extends keyof EventHubTopics>(\n topic: K,\n handler: (data: EventHubTopics[K]) => void\n ): { close: () => void }\n\n // generic escape hatches — see realtime-other-routes.md\n subscribeToSSE<T>(\n path: string,\n handler: (data: T) => void\n ): { close: () => void }\n connectToChannel(\n channelRoute: string,\n protocols?: string | string[]\n ): 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/function'\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')\n .values(data)\n .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>,\n topic: K,\n 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}>\n <App />\n </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 )\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 (\n <ul>\n {todos.map((t) => (\n <li key={t.id}>{t.title}</li>\n ))}\n </ul>\n )\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[realtime-other-routes.md](realtime-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-wiring/references/rpc.md": "# Pikku RPC Wiring\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-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\n```\n\nThe flag says the endpoint exists, not who may call it — each exposed function\nis gated by its own `auth`, permissions and scopes.\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-wiring/references/scheduler.md": "# Pikku Scheduled Tasks\n\n## API Reference\n\n### `wireScheduler(config)`\n\n```typescript\nimport { wireScheduler } from '#pikku/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### Giving a cron an identity\n\nA cron has no caller, so it runs with **no session at all**: it cannot invoke a\npermission- or scope-gated RPC, and nothing it writes can be attributed. A\nscheduled task is a machine principal — give it a session in the task's own\n`middleware`, exactly as a bearer-authenticated caller gets one:\n\n```typescript\nwireScheduler({\n name: 'bookingLifecycleDaily',\n schedule: '0 3 * * *',\n middleware: [cronSession],\n func: bookingLifecycleDaily,\n})\n```\n\n`runScheduledTask` builds its wire with a `sessionService`, so a `setSession`\nhere is the session the function is frozen with. See the machine-auth section of\n`pikku-middleware` for the factory and for why a cron is not a user row.\n\nA scheduler service running a task on someone's behalf can pass a session\ndirectly instead: `runScheduledTask({ name, session })`.\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-wiring/references/trigger.md": "# Pikku Trigger Wiring\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/trigger'\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/trigger'\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/trigger'\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\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-wiring/SKILL.md": "---\nname: pikku-wiring\ndescription: >-\n Use when exposing a Pikku function over a transport — HTTP routes and SSE, WebSocket channels,\n typed realtime pub/sub, internal and exposed RPC, queue workers, cron schedules, event triggers,\n MCP tools/resources/prompts, CLI commands, or a Slack gateway. Covers choosing the wiring, the\n model every wiring shares, and what differs: which function type each needs, where a session\n comes from, and which calls throw instead of returning. TRIGGER when: code uses wireHTTP,\n defineHTTPRoutes, wireChannel, wireQueueWorker, wireScheduler, wireTrigger, wireTriggerSource,\n wireCLI, wireMCPResource, wireMCPPrompt, `mcp: true`, `sse: true`, `expose: true`, rpc.invoke,\n SlackGatewayAdapter, or the user asks how to expose, route, schedule, queue, stream or publish a\n function. DO NOT TRIGGER when: writing the function body itself (use pikku-concepts), declaring\n authorization (use pikku-auth), or serving the app on a runtime (use pikku-deploy).\ninstallGroups: [core]\n---\n\n# Pikku 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\n## Before you start\n\n```bash\npikku info functions --verbose # existing functions, their types, tags, middleware\npikku info tags --verbose # project organisation and naming conventions\npikku info middleware --verbose # what middleware is already applied\n```\n\nFollow the patterns you find. Option tables and exact signatures come from\n`pikku doc` — run `pikku doc --ai` for the installed surface. This skill is what\nthe compiler cannot tell you: which wiring to reach for, and what changes when\nyou move a function from one to another.\n\n## What a wiring is\n\nA **function** owns behaviour, its `input`/`output` schemas, and its\nauthorization. A **wiring** owns only the transport: how a caller reaches that\nfunction. The same function can be wired to several transports at once, which is\nwhy nothing transport-specific belongs in its body.\n\nThree consequences that hold for every wiring below:\n\n- **Input and output types are never declared on the wiring.** They come from the\n function's own `input:`/`output:` schemas. Route params, query params and body\n are merged into the function's `data` argument.\n- **Permissions are never declared on the wiring.** Wire-level permissions were\n removed in #972 — declare them on the function (`pikkuFunc({ permissions })`,\n see `pikku-auth`) or app-wide via `addGlobalPermission`. Tags and\n patterns now target *middleware* only.\n- **The wire is the third argument**, not a service. `channel`, `rpc`, `session`,\n `setSession`, `mcp`, `queue`, `scheduledTask` and `cli` all live there.\n\n## Import from `#pikku/*`, never `@pikku/core/*`\n\nEvery wiring factory has two versions. The generated `#pikku/*` entrypoint binds\nit to your project's service, session and middleware types; the `@pikku/core/*`\nexport is the unbound generic. **Both compile.** Importing from core costs you\nexactly the typing that makes the wiring worth having, silently.\n\n| Wiring | Import from |\n| --- | --- |\n| `wireHTTP`, `defineHTTPRoutes`, `wireHTTPRoutes` | `#pikku/http` |\n| `wireChannel`, `defineChannelRoutes` | `#pikku/channel` |\n| `wireQueueWorker` | `#pikku/queue` |\n| `wireScheduler` | `#pikku/scheduler` |\n| `wireTrigger`, `wireTriggerSource`, `pikkuTriggerFunc` | `#pikku/trigger` |\n| `wireCLI`, `pikkuCLICommand`, `pikkuCLIRender` | `#pikku/cli` |\n| `pikkuMCPToolFunc`, `pikkuMCPResourceFunc`, `pikkuMCPPromptFunc`, `wireMCPResource`, `wireMCPPrompt` | `#pikku/mcp` |\n\n## Pick a wiring\n\n| Reach for | When | Reference |\n| --- | --- | --- |\n| **HTTP** | REST endpoints, web APIs, and SSE streams (`sse: true`, `get` only) | `references/http.md` |\n| **Channel** | A hand-designed WebSocket protocol with your own action routing | `references/channel.md` |\n| **Realtime** | Typed pub/sub push — the scaffolded `/events` channel and SSE topics | `references/realtime.md` |\n| **RPC** | One function calling another, or dispatching a name from outside | `references/rpc.md` |\n| **Queue** | Reliable background work that must survive a crash and retry | `references/queue.md` |\n| **Scheduler** | Recurring work on a cron expression | `references/scheduler.md` |\n| **Trigger** | Reacting in-process to an external event source (Redis pub/sub, PG LISTEN) | `references/trigger.md` |\n| **MCP** | Exposing functions to an AI assistant as tools, resources or prompts | `references/mcp.md` |\n| **CLI** | A terminal program with commands, subcommands and options | `references/cli.md` |\n| **Gateway** | An inbound integration from a third-party product — Slack is the shipped adapter | `references/gateway-slack.md` |\n\nRealtime and Channel are the pair most often confused. If the shape is \"server\npushes typed events to subscribers\", use Realtime — `pikku enable events`\ngenerates the channel, the SSE route and the cleanup for you. Reach for Channel\nonly when the client also sends structured messages you need to route on.\n\n## What differs, and where it bites\n\n### Each wiring demands a particular function type\n\n| Wiring | Function type | Why |\n| --- | --- | --- |\n| HTTP, Channel, Queue, CLI, MCP | `pikkuFunc` / `pikkuSessionlessFunc` | Ordinary request/response |\n| Scheduler | **`pikkuVoidFunc`** | A cron has no input and no caller to return to |\n| Trigger *source* | **`pikkuTriggerFunc`** | Runs **once at startup**, not once per event |\n\nA trigger source is the one that surprises people: it sets up a listener, calls\n`trigger.invoke(...)` per event, and returns a teardown function. It receives\n**singleton services only** — no session, no request, no per-wire services,\nbecause the listener outlives every event it emits.\n\n### Where a session comes from is not uniform\n\nAn HTTP or channel caller arrives with credentials and middleware mints a\nsession. Nothing else does.\n\n- **A cron runs with no session at all.** It cannot invoke a permission- or\n scope-gated RPC, and nothing it writes can be attributed. A scheduled task is a\n machine principal — give it one in the task's own `middleware`, exactly as a\n bearer-authenticated caller gets one. See the machine-auth section of\n `pikku-middleware`.\n- **A queue worker and a trigger handler are the same case.** Whatever identity\n they need is minted in middleware, not inherited.\n- **A channel authenticates per action.** `setSession` is on the wire, and an\n `auth: false` action (conventionally `authenticate`) is how the session is\n established mid-connection.\n- **`auth` on `wireCLI` guards only the generated websocket channel.** A locally\n executed CLI has no connection to authenticate, so it is not a way to require a\n session for local runs.\n\n### Some control-flow calls throw instead of returning\n\n`wire.scheduledTask.skip(reason)` and `wire.queue.discard(reason)` both read like\nan early return and are not — they throw, so nothing after the call runs and no\n`return` is needed. The consequence lands in middleware: a `try/catch` around\n`await next()` catches a skip or a discard and reports it as a failure. If your\nmiddleware distinguishes success from failure, let those pass through rather than\nlogging them as errors.\n\n### Delivery semantics differ, and that is usually the real choice\n\n| Wiring | Delivery | Runs where |\n| --- | --- | --- |\n| Trigger | **At-most-once**, synchronous | In-process, alongside the listener |\n| Queue | **At-least-once** with retries and a dead-letter queue | Distributed workers |\n| Scheduler | Depends on the runtime — see below | Wherever the scheduler service runs |\n\nReach for a trigger to react immediately, and a queue when the work must not be\nlost. A trigger that must not drop events is a queue with extra steps.\n\nScheduled tasks are the trap: on serverless runtimes the same `wireScheduler`\ndeclaration behaves three different ways, and the deployment unit rather than the\ncron expression can decide which tasks fire. `pikku-deploy` has the comparison.\n\n### Codegen owns several wirings — do not hand-write them\n\n| Turn it on | Codegen writes | Never hand-write |\n| --- | --- | --- |\n| `pikku enable rpc` | `rpc-public.gen.ts` — the `POST /rpc/:rpcName` route | A second route on the same path collides |\n| `pikku enable events` | `events.gen.ts` — the `/events` channel and `GET /events/:topic` | A hand-rolled `/events` misses disconnect cleanup |\n| `wireCLI` | `<program>-channel.gen.ts` — the same commands over a channel | It is regenerated on every build |\n\nEnabling the RPC endpoint says the endpoint exists, not who may call it — each\n`expose: true` function is still gated by its own `auth`, permissions and scopes.\n\n## What NOT to do\n\n- Do not import a wiring factory from `@pikku/core/*`. It compiles and silently\n drops your project's types; use the `#pikku/*` entrypoint.\n- Do not put `permissions` on a wiring. They were removed in #972 and belong on\n the function.\n- Do not declare input or output types on a wiring — they come from the\n function's schemas.\n- Do not `return` after `skip()` or `discard()`, and do not let middleware\n report them as failures.\n- Do not hand-write `/rpc/:rpcName`, `/events`, or a CLI's channel file.\n- Do not reach for a trigger when losing an event matters — use a queue.\n- Do not put `sse: true` on anything but a `get`, or `query` on anything but a\n `post`; the config union rejects both rather than failing at runtime.\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```\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({\n method: 'post',\n route: '/onboard',\n func: workflowStart('onboardUser'),\n})\n\n// Execute workflow steps (called by the orchestrator)\nwireHTTP({\n method: 'post',\n route: '/onboard/run',\n func: workflow('onboardUser'),\n})\n\n// Check workflow status\nwireHTTP({\n method: 'get',\n route: '/onboard/status/:runId',\n func: workflowStatus('onboardUser'),\n})\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-wiring) or scheduled tasks (use\n pikku-wiring).\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## Decide FIRST: should this even BE a workflow?\n\nThe deciding question is: **does any part of this cross an external boundary that can fail and MUST NOT be lost or double-run** — a payment authorised/captured through a provider, a third-party API call, an email/webhook, a wait for approval? If yes → workflow (durability, retries, restart-survival, and a visible run). If it's **a single algorithm done in one shot, purely local, and not reused elsewhere** → a plain `pikkuFunc` is correct; do NOT wrap it in a workflow.\n\n- **Checkout WITH payment → workflow.** Get cart → compute total → **(atomic: create order + order items, deduct stock, clear cart)** → **charge payment through the provider** → send confirmation email. It's a workflow because the payment leg (and the email) are external and must be **retried, not lost, and not charged twice** across a restart — and the user benefits from seeing where the run is.\n- **Checkout with NO external payment** — e.g. it just records the order and decrements stock in one transaction, nothing leaves the process — is a **single-shot algorithm**: a plain `pikkuFunc` wrapping one `kysely.transaction`. Not a workflow. A workflow here would add durability machinery for a thing that already commits atomically in one shot.\n- **One durable step is NOT a workflow — it's a queue worker.** A lone side-effect that must be retried / not lost (send one email, fire one webhook, one external charge) → a **queue worker** (`pikku-queue`), enqueued fire-and-forget. A workflow adds a step graph for a thing that has no steps to orchestrate. (A single non-durable step is just a direct RPC call.)\n- Also workflows: onboarding sequences, settlements/payouts, digests and batch sends, anything that waits (`sleep`/`suspend`) or fans out with retries — the common thread is **multiple** steps or a durable wait, never a single step.\n\n**HARD RULE — never a single-RPC (one-step) workflow.** A workflow whose body is one `workflow.do('x', 'someRpc', …)` is a mislabeled durable function, not orchestration. Route by durability, NOT into a workflow:\n\n- **Not durable** — the caller wants the result now / it can just run in-request → **call the RPC directly** (this is also the synchronous path). No workflow, no queue.\n- **Durable** — must be **retried / not lost / survive a restart** (one email, one webhook, one external charge) → a **queue worker** (`wireQueueWorker` + `queueService.add(...)`). Fire-and-forget, retried by the queue.\n\nThere is no \"one-step workflow is justified for the durability\" exception — durability for a single step is a QUEUE. A workflow earns its name only with genuine multi-step orchestration (a `sleep`/`suspend` wait, fan-out, or a saga).\n\n**Atomicity is a TRANSACTION, not a workflow.** All-or-nothing multi-write units (create order + items + deduct stock + clear cart) belong inside ONE `kysely.transaction(async (trx) => { … })` — a single step or a single plain `pikkuFunc` — **never split across workflow steps.** A step is a unit of RETRY and REPLAY, not a unit of atomicity: pikku opens no transaction around `workflow.do`, so a step that does three writes and throws on the third leaves the first two committed, and the retry runs them again. Spreading one logical transaction over several steps is the same failure one level up. Your writes are atomic only where YOU opened a transaction, so open one inside the step (reach for compensating/saga steps only when you truly need cross-service rollback). So a payment checkout is a workflow whose _atomic DB writes are ONE step that opens ONE transaction_, with the payment charge and email as the other durable steps around it.\n\n**A retried step re-runs its side effects.** Replay caching only covers steps that already\nreturned; a step that failed — or that timed out after the provider accepted it — runs again\nfrom the top, so a charge, an email or a webhook can fire twice. Durability is at-least-once,\nnot exactly-once. Pass a stable idempotency key the provider deduplicates on, derived from the\nworkflow's own data rather than generated inside the step:\n\n```typescript\nawait workflow.do('Charge', 'chargePayment', {\n orderId: data.orderId,\n amount: data.amount,\n idempotencyKey: `order-${data.orderId}-charge`,\n})\n```\n\n`randomUUID()` or `Date.now()` inside the step is a different key on every attempt, which is\nthe double-charge. Where the provider has no such header, make the step itself idempotent —\ncheck for the effect before performing it, or record a unique row that the second attempt\ncollides with.\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 — the function leaf does not re-export them (TS2305)\nimport { pikkuWorkflowFunc } from '#pikku/workflow'\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" };
7
+ export const SKILL_FILES = { "pikku-a11y/SKILL.md": "---\nname: pikku-a11y\ndescription: >-\n Accessibility rules (WCAG 2.2) for the app UI: labeled inputs, real buttons/links, keyboard and focus, contrast and not-color-alone, modals, reduced motion.\n TRIGGER when: building forms or any interactive UI, icon-only buttons, modals/drawers, tables/lists with actions, keyboard/focus work, or the user mentions accessibility / screen readers / WCAG.\n DO NOT TRIGGER when: working on backend functions, database, or deployment with no UI.\ninstallGroups: [client]\n---\n\n# Accessibility Rules\n\nMantine components are accessible ONLY when used properly — the rules below are the\n\"properly\". They apply to every page; heading order, landmarks, and image alt text are\ncovered in the `pikku-seo` skill and apply app-wide, not just on public pages.\n\n## Every input has a label\n\n- Use the `label` prop on every Mantine input — a placeholder is NOT a label (it\n disappears on input and is never announced as one). Placeholder = example value only.\n- Use the `error` and `description` props for validation/help text — Mantine associates\n them with the input for screen readers; a loose `<Text c=\"red\">` next to the field\n does not.\n- Icon-only controls (`ActionIcon`, icon `Button`) MUST have `aria-label={m.key()}`\n naming the action (\"Delete item\", not \"Trash icon\").\n\n## Interactive = a real button or link\n\n- Never `onClick` on a `div`/`Box`/`Card` — it is invisible to keyboard and screen\n readers. Use `Button`, `ActionIcon`, `UnstyledButton`, or `<Link>`; navigation is a\n link (href), actions are buttons.\n- Everything reachable by Tab, activatable by Enter/Space. Never remove focus outlines\n (the theme owns the focus ring), never set `tabIndex` greater than 0, never trap focus\n yourself.\n- Whole-row/whole-card click: put the button/link INSIDE with the row as its label —\n don't make the container clickable and unfocusable.\n\n## Don't say it with color alone\n\n- Status must carry text or an icon, not only a color: a Badge says \"Overdue\", a form\n error has a message — a red tint by itself is invisible to colorblind users.\n- Contrast comes from the theme; don't undermine it by stacking `c=\"dimmed\"` on small\n text over tinted backgrounds. Body copy stays at least AA-readable.\n- Touch targets: WCAG 2.2 minimum 24px — don't shrink `ActionIcon`/`Checkbox` below\n size `sm`, and keep adjacent row actions spaced.\n\n## Overlays and motion\n\n- Modals/drawers: use Mantine `Modal`/`Drawer` and ALWAYS pass `title` — that is what\n gets announced; focus trap and Escape come built in. (This project uses drawers, not\n dialogs.)\n- Landing-page animation (the only custom-CSS surface) respects\n `prefers-reduced-motion: reduce` — gate transforms/parallax behind the media query.\n\n## Self-check before declaring UI done\n\nTab through the page once: every control reachable and visibly focused, every input\nlabeled, every icon button named, every status readable without color. A browser\nscenario proves the flow works, not that it is reachable without a mouse — this\nmanual pass is the only check that does.\n", "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 # imports -> dist, exports -> dist\n├── pikku.config.json # addon: true + metadata\n├── tsconfig.json # #pikku path mapping (source side)\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/addon/ # Generated (gitignored)\n```\n\nAn addon's generated tree roots one level down, at `.pikku/addon/`, so its own\nleaves are reached as `#pikku/addon/<leaf>` while an application's are\n`#pikku/<leaf>`. `paths` are global to a tsx process rather than scoped to the\npackage that declared them, and the extra segment is what stops a linked addon's\n`#pikku/function` from matching the *host application's* flat leaf.\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`outDir` stays `./.pikku`; `addon: true` is what appends the `addon` segment.\n\n## package.json (key fields)\n\n```json\n{\n \"name\": \"@my-org/addon-todos\",\n \"imports\": {\n \"#pikku/*.js\": \"./dist/.pikku/*.js\",\n \"#pikku/*\": [\"./dist/.pikku/*/index.js\", \"./dist/.pikku/*\"]\n },\n \"exports\": {\n \".\": { \"types\": \"./dist/src/index.d.ts\", \"import\": \"./dist/src/index.js\" },\n \"./.pikku/*\": \"./dist/.pikku/addon/*\",\n \"./.pikku/pikku-metadata.gen.json\": \"./dist/.pikku/addon/pikku-metadata.gen.json\",\n \"./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js\": {\n \"types\": \"./dist/.pikku/addon/rpc/pikku-rpc-wirings-map.internal.gen.d.ts\"\n }\n },\n \"files\": [\"dist\"],\n \"peerDependencies\": {\n \"@pikku/core\": \"*\",\n \"zod\": \"^4\"\n },\n \"scripts\": {\n \"prebuild\": \"pikku all\",\n \"pikku\": \"pikku all\",\n \"build\": \"tsc && cp -r .pikku types dist/\"\n }\n}\n```\n\n**`imports` names `dist`, never the source tree.** `files: [\"dist\"]` is the whole\npublished package, and `build` copies `.pikku` and `types` into it — so a\n`#pikku/*` target under `./.pikku/` resolves for the author and for nobody else.\nIt is a silent break: the addon compiles, packs, installs and then throws\n`Cannot find module '.../.pikku/addon/function/index.ts'` on first import in the\nconsuming app, out of a file the consumer never wrote. The addon's own build\ndoes not read `imports` at all — tsconfig `paths` covers it, which is why the\ntwo maps point at different trees.\n\n**`exports` targets carry the `addon` segment; the subpaths do not.** A consumer\nwrites `@my-org/addon-todos/.pikku/rpc/...`, exactly as it would in an\napplication, and the leaf stays the package's own business.\n\n## tsconfig.json (key fields)\n\n```json\n{\n \"compilerOptions\": {\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"rootDir\": \".\",\n \"outDir\": \"./dist\",\n \"paths\": {\n \"#pikku/*.js\": [\"./.pikku/*.ts\"],\n \"#pikku/*\": [\"./.pikku/*/index.ts\", \"./.pikku/*\"]\n }\n },\n \"include\": [\"src/**/*\", \"types/**/*\", \".pikku/**/*.ts\"],\n \"exclude\": [\"node_modules\", \"dist\", \".pikku/**/*.d.ts\"]\n}\n```\n\n`paths` resolves the source tree because `dist` does not exist yet on the build\nthat creates it. Both patterns are needed: the `.js` one reaches a generated\nfile (`#pikku/addon/variables/pikku-variables.gen.js`), the bare one reaches a\nleaf's barrel (`#pikku/addon/function`). Keep the `.js` pattern first: both keys\nshare the `#pikku/` prefix, and TypeScript takes the first match of the longest\nprefix rather than the most specific pattern. Node sorts by specificity and does\nnot care about the order.\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-wiring) 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/addon'\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/function'\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/function'\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/setup'\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-services`). `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/setup'\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/setup'\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\nAn addon's generated tree roots at `.pikku/addon/`, but its `imports` map points\n`#pikku/*` there, so it authors against the same subpaths an application does —\n`#pikku/function`, `#pikku/http`. The `addon` segment is the package's own\nbusiness, never part of a specifier.\n\n```typescript\n// src/functions/addTodo.function.ts\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\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/addon'\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 } from '#pikku/http'\nimport { ref } from '#pikku/function'\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 } from '#pikku/http'\nimport { ref } from '#pikku/function'\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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\nexport const todoAgent = pikkuAgent({\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-agent` — an addon function is just another `ref()` in `tools`.\n", "pikku-admin-to-fabric/SKILL.md": "---\nname: pikku-admin-to-fabric\ndescription: 'Port a legacy back-office admin (ActiveAdmin, Django admin, Rails Admin, Laravel Nova, Filament) to Fabric admin screens, driven by a `.knowledge/` Product Blueprint. Covers the admin-DSL→Fabric mapping (resources→screens, index/column→tables, filter→query params, scope→query variants, member_action/collection_action→pikkuFuncs, permit_params→input schemas), the \"the admin is half your app\" audit, and admin-specific permissions. TRIGGER when: porting/rebuilding a legacy app that has a generated/DSL-driven admin, or the user says \"port the admin screens\" / \"implement the admin\". DO NOT TRIGGER when: no legacy admin exists (use pikku-fabric to build screens fresh), or the app is being extended rather than ported (use pikku-build).'\ninstallGroups: [fabric]\nargument-hint: '<path to .knowledge/> [resource to port next]'\n---\n\n# Legacy admin → Fabric admin screens\n\n## Agent Operating Procedure\n\n1. **Count first.** How many commands cite the admin? That number decides whether this is a chore or a third of the project.\n2. **The blueprint already has the commands.** Do not re-derive them from the DSL. Map to them.\n3. **Port the actions before the screens.** A screen with no action behind it is a table; the actions are the product.\n4. **One resource per slice**, same as `pikku-blueprint-to-fabric`. Verify green before the next.\n5. **An admin permission is not a checkbox.** Legacy admins routinely authenticate and do not authorize. Do not port that.\n6. **Record what you did NOT port**, per resource, in the parity report.\n\n## The mistake this skill exists to prevent\n\n> \"It's just the admin — CRUD screens over the same tables. We'll scaffold it at the end.\"\n\nThis is wrong in a specific, measurable way, and you can check it in one command\nbefore you believe anything else in this file:\n\n```bash\nnode -e \"\nconst c = require('./.knowledge/commands.json').commands\nconst admin = c.filter(x => (x.evidence||[]).some(e => (e.file||'').match(/admin/)))\nconsole.log(admin.length + ' of ' + c.length + ' commands live in the admin')\n\"\n```\n\nOn a real Rails app (Applause, 39 ActiveAdmin resources, 5,545 lines of DSL) the\nanswer was **82 of 187 — 44%**, plus 71 of 223 API surfaces. The admin was not a\nside panel over the customer-facing app. It was nearly **half the application's\nwrite surface**, and a large share of those commands existed *nowhere else*:\nissue a refund, retrigger a payment, queue a sync, impersonate a user, mark a\nblade returned, reassign a company. There is no customer screen for any of them.\n\nSo: the admin is not the last 10% of the port. Budget it as what the count says.\n\n**Corollary — the admin is where the unguarded capabilities live.** A\n`member_action :impersonate` or `:create_stripe_refund` is a command that moves\nmoney or identity, defended in legacy by nothing more than \"you reached an\n`/admin` URL\". Every one of these needs a real `pikkuPermission` in the rebuild,\nand writing them is the point of the port, not overhead on top of it.\n\n## Stage 0 — Preflight\n\n- The `.knowledge/` blueprint must exist and validate (`0 error(s)`). If not, run\n **pikku-software-archaeology** first. This skill maps to the blueprint; it does\n not parse Ruby.\n- Read `parity-*.md` for the domains you are about to touch. Renames decided in an\n earlier slice (a `tenant` that became a `Market`, a `membership_level` that\n became a `certification_level`) are binding here. An admin screen that reintroduces\n the old word undoes the decision.\n- Run the count above and say the number out loud in your plan.\n\n## Stage 1 — Inventory the DSL\n\nEvery generated admin is the same six ideas under different syntax. Inventory\nthem, do not read them line by line:\n\n```bash\n# ActiveAdmin\ngrep -rhoE \"^\\s{0,4}(index|show|form|filter|scope|action_item|member_action|collection_action|batch_action|permit_params|csv|sidebar|panel|actions)\\b\" app/admin/*.rb | sort | uniq -c | sort -rn\ngrep -rhoE \"(member_action|collection_action) :[a-z_]+\" app/admin/*.rb | sort -u\n```\n\n| Legacy | Also called | Becomes in Fabric |\n|---|---|---|\n| `ActiveAdmin.register X` / `class XAdmin` | resource, ModelAdmin, Nova Resource | one TanStack route + one screen |\n| `index do … column :x` | `list_display`, `columns()` | a Mantine `Table`/`DataTable`, columns from the query's return type |\n| `filter :x` | `list_filter`, `searchable` | typed input fields on the list query |\n| `scope :active` | `get_queryset` variants, `Nova::Filters` | a named variant of the list query — NOT a new function per scope |\n| `form do f.input …` | `fields()`, `fieldsets` | a Mantine form; inputs from the command's zod input schema |\n| `permit_params` | `fields`, `$fillable` | you already have this: it is the command's input schema. Cross-check, don't re-derive. |\n| `member_action :foo` | custom action, `Nova::Actions` | **a `pikkuFunc`** — almost always already in `commands.json` |\n| `collection_action :foo` | bulk action | a `pikkuFunc` taking a set |\n| `batch_action` | admin action | a `pikkuFunc` taking ids[] |\n| `csv do … end` | export | a readonly func returning rows; render client-side |\n| `panel`/`sidebar` | inlines, `relations` | a section on the show screen, fed by a related query |\n| `action_item` | — | a button. It is not a capability; find the action it calls. |\n\n**`action_item` vs `member_action` is the distinction that matters.** An\n`action_item` is a *button*; a `member_action` is a *capability*. Legacy files\npair them, and a fast reader counts the buttons. Count the capabilities.\n\n## Stage 2 — Map actions to blueprint commands (do this before any UI)\n\nFor each `member_action`/`collection_action`, find its command in\n`commands.json`. Three outcomes, and the third is the valuable one:\n\n1. **Found** — the archaeology already lifted it (`ArchiveProduct`,\n `CancelInvoice`). Wire the screen to it. Nothing to build.\n2. **Found under a different name** — the blueprint names concepts in domain\n language, the DSL names them after routes. `member_action :rerun` may be\n `RetryWebhookDelivery`. Match on behaviour, not spelling. **Use the blueprint's\n name.**\n3. **Not found** — stop. Either the archaeology missed a command (fix\n `.knowledge/`, do not paper over it here) or the action is dead code. Both are\n findings. Do not quietly invent a command to fill the gap: a command with no\n blueprint entry has no evidence, no policy and no actor, and you will not\n notice which.\n\nTwo legacy shapes to expect and *not* reproduce:\n\n- **`*_form` + `*` action pairs** (`create_stripe_refund_form` +\n `create_stripe_refund`, `issue_payment_form` + `issue_payment`). The `_form` half\n is a GET that renders a modal — it is a *screen*, not a capability. It collapses\n into the screen; only the second half is a `pikkuFunc`. Porting both doubles your\n command count with phantoms.\n- **A `member_action` that only redirects** to another action. That is routing.\n- **An action disabled in the production environment is not a live capability.**\n Grep the environment guards before porting:\n ```bash\n grep -rn \"env.production?\\|env\\.development?\\|ENV\\[\" app/admin/*.rb\n ```\n On Applause, both halves of `create_stripe_refund` open with\n `return redirect_to … if Rails.env.production?` — the admin refund screen has\n never run in production, and refunds are actually issued in the Stripe dashboard.\n Porting it faithfully would ship a prominent button for a capability the business\n does not use through this app, and quietly move refunds into a surface nobody has\n ever tested. Whether it should now exist is a **product decision**, not a port.\n Check the guard is on the *mutating* half too: if the form is blocked and the POST\n is not, you have found a hole rather than a dead feature.\n\n## Stage 3 — Permissions (the part legacy skipped)\n\nGenerated admins authenticate and then trust. The whole admin sits behind one\n\"is an admin\" check, and every action inside it is equally reachable — refunds,\nimpersonation and editing an FAQ all guarded identically.\n\n- Read `policies.json` for the real rule per command. If the blueprint says the\n policy is `enforcedBy: nothing`, that is a **gap you are now closing**, not a\n behaviour to port.\n- With Better Auth's `admin()` plugin, `user.role` is the platform role and\n `session.impersonated_by` is set during impersonation. Both are yours already.\n- **Money and identity actions deserve their own permission**, not the blanket one.\n If the blueprint offers no rule, that is a `decisionsNeeded` entry — ask, do not\n invent.\n- **Impersonation:** Better Auth's `impersonateUser`/`stopImpersonating` replace the\n hand-rolled version. If the legacy audit table recorded only the *start* of an\n impersonation (no `ended_at`, no session id), do not port it — the Fabric audit\n table already answers \"what did they do while impersonating\", which was the whole\n question it failed to answer.\n\n## Stage 4 — Screens\n\n- The list query is `pikkuSessionlessFunc` + `readonly: true`; filters and scopes\n are **input fields on one function**, not one function per scope. Legacy needs a\n method per scope because the DSL has no parameters. You do not.\n- Columns come from the query's return type. If a column exists in the DSL but not\n in the type, the DSL was computing it in Ruby per row — that is an N+1 wearing a\n column, and it belongs in the query.\n- Reuse the app's Mantine theme. An admin styled differently from the product is\n how design systems fork.\n- **Server-computed charts** (chartkick/groupdate and friends) do not port. The\n aggregation becomes a real query; the plot becomes a chart component. This is the\n one genuinely expensive screen in most admins — cost it separately.\n- **Drag-and-drop reordering** (`acts_as_list`, sortable tables) is custom logic,\n not a table. Port the position semantics deliberately.\n\n## Stage 5 — Verify and report\n\n```bash\npikku all && pikku fabric validate --json\n```\n\nPer resource, the parity report records:\n\n- **Ported** — screens + which blueprint commands back them.\n- **Deliberately not ported** — with the reason. Expect: `*_form` halves, dead\n actions, single-member enums, screens over dropped tables.\n- **Now authorized** — every action that was guarded by \"reached an /admin URL\"\n and now has a real permission. This is the port's dividend; name it.\n- **Still open** — actions whose rule the blueprint could not settle.\n\n## Red Flags\n\n| Thought | Reality |\n|---|---|\n| \"The admin is just CRUD, scaffold it last\" | Run the count. It was 44% of commands on a real app, and those commands exist nowhere else. |\n| \"I'll read the DSL and write the commands\" | The blueprint already has them, with evidence, actors and policies. Map; don't re-derive. |\n| \"One function per scope\" | A scope is a filter argument. The DSL needed a method because it has no parameters. |\n| \"`action_item` count = capability count\" | Buttons aren't capabilities. Count `member_action`/`collection_action`. |\n| \"Port `create_stripe_refund_form` too\" | It is a GET that renders a modal. It is a screen. Only the non-`_form` half is a command. |\n| \"Admins are admins; one permission is fine\" | That is the legacy bug. Refunds and FAQ edits are not the same risk. |\n| \"The admin action isn't in commands.json, I'll add it\" | Stop. Either the archaeology missed it (fix the blueprint) or it is dead. Both are findings. |\n| \"I'll restyle the admin, it's internal\" | An admin off the product's theme is how a design system forks. |\n\n## Quick Reference\n\n```bash\n# 1. how much of the app is actually the admin?\nnode -e \"const c=require('./.knowledge/commands.json').commands;console.log(c.filter(x=>(x.evidence||[]).some(e=>(e.file||'').match(/admin/))).length+'/'+c.length)\"\n\n# 2. inventory the capabilities (not the buttons)\ngrep -rhoE \"(member_action|collection_action) :[a-z_]+\" app/admin/*.rb | sort -u\n\n# 3. per resource: map actions -> commands.json, then build the screen\n# 4. verify\npikku all && pikku fabric validate --json\n```\n\n## Related skills\n\n- **pikku-software-archaeology** — produces the `.knowledge/` blueprint this needs.\n- **pikku-blueprint-to-fabric** — the parent port; run this per-domain alongside it.\n- **pikku-auth** — roles, ban and impersonation, and the scopes that gate them.\n- **pikku-fabric** — screens, theme, Mantine conventions.\n", "pikku-agent/references/agents.md": "# Pikku AI Agent Wiring\n\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### `pikkuAgent(config)`\n\nImport it from the generated agent types file — `#pikku` does not re-export it:\n\n```typescript\nimport { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\npikkuAgent({\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. 'agentStorage')\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 agentMiddleware?: PikkuAgentMiddlewareHooks[],\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-auth`.\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`runAgent` / `streamAgent` from `@pikku/core/agent` are the layer beneath\nthis. Their third argument is `RunAgentParams` (`{ 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 `AgentStreamEvent`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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\nexport const todoAgent = pikkuAgent({\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: 'agentStorage', lastMessages: 20 },\n maxSteps: 10,\n toolChoice: 'auto',\n})\n```\n\n### Scaffold the HTTP surface\n\n```bash\npikku enable agent\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 = pikkuAgent({\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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { ref } from '#pikku/function'\n\nexport const todoAssistant = pikkuAgent({\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: 'agentStorage', 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-agent/references/runner-vercel.md": "# Pikku AI Vercel (Agent Runner)\n\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### `VercelAgentRunner`\n\n```typescript\nimport { VercelAgentRunner } from '@pikku/ai-vercel'\n\nconst runner = new VercelAgentRunner(\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: AgentRunnerParams, channel: AgentStreamChannel): Promise<AgentStepResult>` — Stream AI responses with tool calls\n- `run(params: AgentRunnerParams): Promise<AgentStepResult>` — Execute a single AI step (non-streaming)\n- `transcribe({ model, audio, … })` / `generateSpeech({ model, text, voice, … })` — what `voiceInput`/`voiceOutput` call; see `references/voice.md`\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 { VercelAgentRunner } 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, agentRunner: new VercelAgentRunner(providers) }\n})\n```\n\nThe service key is **`agentRunner`** — 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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\n\nexport const assistant = pikkuAgent({\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 `wireAgent` — agents are declared with `pikkuAgent` from the\ngenerated agent types. See `references/agents.md` 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 VercelAgentRunner({ '*': createMockLlmProvider() })\n```\n", "pikku-agent/references/voice.md": "# Pikku AI Voice (Speech I/O)\n\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/agent`** as two AI middlewares, and the\nspeech models are reached through the `agentRunner` (`transcribe` /\n`generateSpeech`) rather than through separate services. See `references/runner-vercel.md`.\n\n## API Reference\n\n```typescript\nimport { voiceInput, voiceOutput } from '@pikku/core/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 **`agentMiddleware`** array, not a\n`middlewareHooks` option, and the agent is declared with `pikkuAgent` — there\nis no `wireAgent`.\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 `agentRunner.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 { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'\nimport { voiceInput, voiceOutput } from '@pikku/core/agent'\n\nexport const voiceAssistant = pikkuAgent({\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 agentMiddleware: [\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-agent/SKILL.md": "---\nname: pikku-agent\ndescription: >-\n Use when building AI agents, chatbots or LLM-powered assistants with Pikku — pikkuAgent, ref()\n tool registration, memory, streaming, tool approval, thread ownership, invocation via rpc.agent,\n the VercelAgentRunner and its provider map, and the voiceInput/voiceOutput middlewares. TRIGGER\n when: code uses pikkuAgent/rpc.agent/runAgent/streamAgent/VercelAgentRunner/voiceInput, user asks\n about AI agents, chatbots, tool-calling, agent memory or streaming, model providers, speech in or\n out, or `pikku enable agent`. DO NOT TRIGGER when: user asks about MCP tool exposure (use\n pikku-wiring), workflows (use pikku-workflow), or general function definitions (use\n pikku-concepts).\ninstallGroups: [core]\n---\n\n# Pikku AI Agents\n\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: how an\nagent reaches the rest of the app, and which of its knobs mean something other\nthan what they look like.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Defining or invoking an agent — tools, memory, streaming, approval, threads | `references/agents.md` |\n| Wiring the runner, or pointing model strings at a provider or gateway | `references/runner-vercel.md` |\n| Adding speech in or out of an agent | `references/voice.md` |\n\n## An agent is a function that reaches other functions\n\nTools, sub-agents and workflows are all supplied as `ref('domain:funcName')`\nhandles rather than imported values. The inspector resolves each ref against the\ngenerated function map, which is what lets an agent call into another package or\na `graph:*` builtin without an import cycle — and what lets the tool menu be\nfiltered per session before the model ever sees it.\n\nInvoke through `wire.rpc.agent` from inside a Pikku function; it carries the\nsession, the credentials and the RPC depth for you.\n\n## The knobs that do not mean what they look like\n\n- **`goal` is the prompt field, and it is required.** There is no `instructions`\n key. `role`, `personality` and `goal` are concatenated in that order and\n nothing validates which text lands where, so the split buys legibility only.\n- **`output` is honoured only when the agent has no tools.** A structured-output\n schema on a tool-calling agent is silently inert.\n- **`auth` defaults to `false`**, because agents are normally invoked from an\n already-authenticated `pikkuFunc`. `scopes` and `permissions` are enforced\n either way — see `pikku-auth`.\n- **`approvalRequired` sits on the tool function, not on the agent.** The run\n then resolves `status: 'suspended'` with `pendingApprovals`; answer with\n `rpc.agent.approve(runId, approvals)`.\n- **Model strings split on the first slash only** — `provider/model`, so\n `'ollama/qwen2.5:7b'` is fine and a string with no slash throws rather than\n defaulting to a provider.\n\n## What NOT to do\n\n- **Do not trust a caller-supplied `resourceId` as an owner.** It never is: the\n session's principal (`userId`, or `orgId` under `sessionScope: 'org'`) is\n prefixed onto it, so a client sub-partitions inside its own boundary and cannot\n read across one. A sessionless run gets an ephemeral anonymous owner.\n- **Do not rely on the tool menu alone for authorization.** It is two-layer — the\n session decides which tools an agent can see, and the function's own\n `permissions` still guard the call when the model picks one.\n- **Do not point the `'*'` provider at a single vendor.** It resolves every\n provider name with no exact entry, so aimed at one vendor an `anthropic/…`\n string silently reaches OpenAI. Point it at a gateway or a scripted test\n provider — something that genuinely accepts arbitrary model names.\n- **Do not add `@pikku/ai-voice` as a dependency.** It still publishes but its\n entire source is `export {}`. Voice is two middlewares in `@pikku/core/agent`,\n with the speech models reached through the `agentRunner`.\n", "pikku-architect/SKILL.md": "---\nname: pikku-architect\ndescription: >-\n Use to turn one settled milestone note into the technical plan the build is measured against —\n the tables, functions, wires, roles, scopes, screens and scenarios it owes, split into passes and\n written through `pikku knowledge plan set`. This is a SEPARATE SEAT from the build: the plan is\n the denominator `pikku knowledge plan progress` divides by, so whoever writes it must not be the\n one grading themselves against it. TRIGGER when: a milestone note is settled and the next step is\n planning it, the user asks to plan or architect a milestone, `pikku knowledge plan progress` says\n a milestone has no plan, or pikku-build's App mode reaches a milestone with nothing planned. DO\n NOT TRIGGER when: the milestone notes themselves are still being written (use pikku-knowledge),\n the plan already exists and the job is to build it (use pikku-build), or the ask is a one-off\n edit to a working app.\ninstallGroups: [core]\n---\n\n# Plan one milestone\n\nA milestone note says what the app must DO and how it must feel for the person using it. It\ndeliberately does not say how. You are the seat that decides how, once, in writing, before anyone\nbuilds it.\n\n**Why this is a separate seat.** The build agent used to write its own plan. That makes one party\nboth author and examiner: it can build a fraction, plan only that fraction, and certify itself\ncomplete — and `pikku knowledge plan progress` then divides by a denominator the builder chose\nafter seeing its own answer. A plan written here, against the note, by someone who is not going to\nbuild it, is the denominator the builder does not own.\n\n**One milestone, one plan, then stop.** Do not build in this session. Do not plan the next\nmilestone \"while you are here\" — the notes after this one are still allowed to change, and a plan\nwritten against a note that later moves is worse than no plan.\n\n---\n\n## Write nothing by hand\n\nThe plan reaches disk through `pikku knowledge plan set <milestone> <file>` and nowhere else. It\nvalidates first and names the field that is wrong if it refuses; a plan file written with an editor\nis a plan nothing checked, and the place that discovers that is a finished build.\n\nIt is JSON rather than a note on purpose. Everything else under `knowledge/` is prose a human\nreads; this one is consumed field-by-field, and a markdown parser is one more place a misspelt\nheading silently passes. It cannot live INSIDE the milestone note either: that note is frozen once\nits status leaves `proposed`, so rewriting it would change what the builder was told.\n\n## Send it. Do not go looking.\n\n```sh\npikku knowledge plan schema\n```\n\nThat is the specification, in full, with every field's guidance in its `description`. There is no\nsecond plan-format doc. So when you are unsure what a field wants, **write your best honest reading\nand send it** — `plan set` validates every field and names the exact one that is wrong, so a wrong\nguess costs one round trip and teaches you the answer.\n\nThe failure mode to recognise in yourself: you have decided the tables, the passes and the\nfunctions, and you are still reading. That is the moment to run `plan set`.\n\n---\n\n## The turn\n\n### 1. Read what has been settled\n\n```sh\npikku knowledge validate # the base is consistent before you plan against it\npikku meta context --json # what the app already declares\npikku knowledge plan schema # the only spec for what you are about to write\n```\n\nThen read, in the tree: the milestone's own note in full, every note it names on `entities:` and\n`requires:`, the decisions that constrain it, and the migrations already in `db/sqlite/` — those\nsay whether your tables are new or an alter.\n\n**Do not re-interview.** If the note leaves something genuinely undecided, plan the reading that\nbuilds LESS. A smaller milestone that ships is worth more than a complete one that does not, and\nwhat you leave out is named in `covers` for the next milestone to pick up.\n\n### 2. Decide the passes\n\nA pass is a slice of the milestone that stands up on its own. **Pass 1 is a walking skeleton**: it\nreaches a real screen, with real functions behind it, proved by a real browser scenario. Everything\nelse waits behind it.\n\nThis is enforced, not advisory — `plan set` refuses a plan whose pass 1 has no `ui` item, no\n`functions` item, or a pass-1 route with nothing proving it works. The reason is the failure it was\nwritten against: a milestone that built four unwired functions and no page, and reported itself\nfinished. A build that runs out of time in pass 2 has shipped something; one that runs out of time\nhaving built pass 1 across four half-finished layers has shipped nothing.\n\n**Only pass 1 blocks.** `pikku knowledge plan progress` reports a later pass under `deferred` and\nnever refuses on it. That is what stops plan size from being fatal — but it is not licence to plan\na milestone nobody could finish. The question that decides a plan's size is not \"what does this\nnote imply\" but **\"could a build finish all of this if pass 1 took twice as long as I expect\"** — if\nnot, it is two milestones. Plan the first, and say in `covers` what you left behind.\n\n**A screen is what pass 1 reaches only when the milestone IS an app.** The note's `surface:` says\nwhich it is — absent means an app, and `cli`, `mcp`, `agent` and `backend` are the others. On those,\n`ui` is legitimately `n/a` (with its reason, like any slot), and pass 1 proves itself one level\ndown: a pass-1 function that is actually wired, and a `scenarios.backend` item carrying that\nfunction's name in its `fn` field. The obligation never lifts, it only moves — read the surface off\nthe note before you decide the passes.\n\n### 3. Say what each slot is, or say why it is nothing\n\nEvery slot — `model`, `functions`, `roles`, `scopes`, `ui`, and each level of `scenarios` — is\neither `{\"kind\": \"built\", \"description\": ..., \"items\": [...]}` or `{\"kind\": \"n/a\", \"description\":\n...}`. **Both carry prose.**\n\nThere is no way to leave a slot out, and that is the point: \"no roles, because everyone using this\napp is the same kind of person\" and \"nobody thought about roles\" must not look alike. Write the\n`n/a` reason as a sentence a reader would accept, not as the word \"none\".\n\n### 4. Write it\n\n```sh\npikku knowledge plan set <milestone> /tmp/plan.json\n```\n\nWrite the JSON to a file first — the command takes a path, not inline JSON, which is what keeps an\napostrophe in a `description` from ending a shell argument. If it is refused, the refusal names the\nfield path. Fix that field and send it again; do not restructure the plan around a refusal you have\nnot read.\n\nThen confirm what the builder will be handed:\n\n```sh\npikku knowledge plan show <milestone> --for-build\n```\n\n---\n\n## What the plan holds\n\nThe plan holds INTENT. Reality lives in pikku's generated meta under `.pikku/`, which already\ninventories every function, wire, scope, role, workflow, agent and scenario. Nothing here\nduplicates that — only what codegen cannot infer: **why a thing exists, which pass it belongs to,\nand which knowledge note it discharges.**\n\n### `covers` — which notes this milestone discharges\n\nEvery plan claims at least one knowledge note: `note` (its path under `knowledge/`), `hash` (what\nthat note's body hashes to right now) and `complete`.\n\n`complete: false` is the honest answer for a note whose claims span several milestones — claim the\nwhole of a note only when this milestone genuinely leaves nothing of it unbuilt, because a note\nmarked complete is a note nobody looks at again.\n\n**You do not have to compute the hash.** Write anything twelve characters long and send the plan:\n`plan set` refuses a hash that is not the note's current one and names the correct one, so one\nround trip gets you every hash in the plan. That refusal is the point of the field — a hash that\nwas never right makes the note read as edited-since from the moment the milestone ships, and it\ndrops back into a backlog nobody planned.\n\n### `model` — tables, and what their columns HOLD\n\nEach field carries a `classification`: `public`, `internal`, `personal` or `sensitive`. That is what\nlets a permission claim be checked against the data rather than only against itself — a function\nreturning a `personal` column with no permission rule is a defect the gate can name. It is also what\n`db/annotations.ts` ends up expressing, so plan it here rather than discovering it at migrate time.\n\nEach relationship carries `onDelete`: `cascade`, `restrict` or `orphan`. A foreign key states which\nrows are related; it does not state what the product wants when the parent goes, and those three\nproduce identical schemas until someone deletes something. A `cascade` is checked against the\nmigrations by `plan progress`, and needs `provedBy` naming a scenario in this same plan that deletes\nthe parent and asserts the children are gone.\n\nA table that already exists is altered by a NEW forward migration, numbered on from the ones in\n`db/sqlite/`. Editing an applied migration is the hash mismatch that makes a deployed database\nrefuse to migrate, so plan the alter as its own file.\n\n### `functions` — with their wire and their rule on them\n\nThe wire and the permission live ON the function, because that is where pikku enforces them. Two\nparallel lists are two lists that drift.\n\n**Do not give a function a `wire`.** pikku already serves every `expose: true` function as an RPC\nand the client calls it by name, so for nearly every function there is nothing to decide — leave the\nfield out. A `wire` is for the exceptions: its own HTTP path via `wireHTTP` (a webhook, a payment\ncallback, a public URL another system posts to), a queue job, a channel, a scheduled task, or a\nworkflow entry point. Those last two are not alternate URLs — they are what the milestone IS, and a\nplan that omits them ships a `status` column nothing advances or a job nobody runs.\n\n`permission` is a SENTENCE, not a role name — \"only the person who wrote it can edit it\". The roles\nare the engineer's choice; the rule is the part that has to survive being implemented, in the\nfunction's `permissions` field and never in its body. `null` means open to anyone signed in, and\nstating that is different from omitting it. **Every function with a permission rule needs a\npermission scenario naming it in `fn`** — a rule with no failing case is a claim, not a check, and\n`plan set` refuses the plan without one.\n\n### `scopes` — what a KIND of user may do, never who owns a row\n\nA scope depends ONLY on the session: \"may this kind of user do this at all\" — `admin:invoices:void`,\n`billing`. It is declared with `wireScope` and granted in `mapSession`, so every name here has to\nend up in pikku's generated scope meta. One that cannot be declared is one the build can never\nfinish, and `plan progress` refuses the milestone for as long as it stands.\n\nOwnership is not a scope. \"Only the owner of the house may read it\" depends on the row being asked\nfor, and a scope never sees the row — that is the function's `permission` sentence and lives nowhere\nelse. If the rule mentions the record, it is a `permission`; if it reads the same for every row that\nuser touches, it is a scope. An app built from one person's idea usually has none at all, so\n`{\"kind\": \"n/a\", ...}` is the ordinary answer here.\n\n### `roles` — and the app each one signs into\n\nThe distinct `app` values across `roles` ARE the frontends this project gets, and nothing downstream\ncan recover the answer. Colleagues share ONE app and differ by nav and permitted actions (the\nmechanic, the person on the counter, the bookkeeper); someone across the counter with an account\ngets their own (the customer, the tenant, the patient). One app is a real answer and often the right\none — then every role carries the same slug. Never invent a person the notes do not name in order to\nreach two, and never give a slug to someone who never signs in: a guest checking out takes the same\nslug as the seller they buy from, on that app's public routes outside `/app`. Once there is more\nthan one app, every `ui` item carries its `app` too.\n\nAdding the second frontend is the BUILD's job, at the milestone that first needs it —\npikku-build's multi-app reference. Your part is recording which app each person is in.\n\n### `ui` — routes, and what is on them\n\nOne item per route, each with the pass that builds it. A pass-1 route has to be LINKED to the\nscenario that proves it, and there are two ways: name the scenario in that `ui` item's own\n`scenarios` array, or write a browser scenario whose `feature` contains the route path. Nothing else\ncounts — an unlinked browser scenario reads as a route nobody proved, and the plan is refused.\n\n### `scenarios` — keyed by level\n\n`backend`, `browser`, `permission`, each its own slot. Keyed rather than tagged so that a plan with\nfour backend scenarios and no browser scenario fails on its SHAPE — a flat list lets that through,\nand that is exactly the milestone that builds an API and ships no screen.\n\n**Every scenario needs `name`: the `pikkuScenario` export it becomes** (`saveEntryScenario`).\n`feature` and `scenario` are prose for a reader, and prose cannot be matched against codegen.\n`plan progress` looks for the export by that exact name, so a scenario with no name is one the gate\ncannot see.\n\nPermission scenarios default to pass 2 — they harden a journey that has to exist before they can\ncover it — so a role × resource cross product there costs the milestone nothing.\n\n---\n\n## What makes a plan wrong\n\n`plan set` catches the mechanical failures. These are the ones it cannot:\n\n- **A plan for a different milestone.** The note is about `entries`; the plan builds `projects`.\n Every entity the note names must appear in a function or a table.\n- **A pass 1 that is a layer, not a slice.** \"Pass 1: the data model. Pass 2: the API. Pass 3: the\n screens.\" That is three passes of nothing working.\n- **Scenarios that assert the code ran rather than that the person got what they came for.** A\n scenario proving `saveEntry` returns 200 proves the wire. The one worth planning is the one where\n a person writes something, comes back, and it is still there. A browser scenario that opens a page\n and asserts it is still on it proves the route loads and nothing else —\n `pikku knowledge plan progress` names it as a problem and refuses the milestone.\n- **A permission rule invented here.** If the notes do not say who may do a thing, the answer is\n `null` with the reason, not a rule you made up. A rule the user never agreed to is one they find\n out about by being locked out of their own app.\n\n---\n\n## When you are done\n\nThe accepted `plan set` is the end of the seat. Hand the milestone to `pikku-build`, which reads the\nplan with `plan show --for-build`, builds it, and closes the milestone only when\n`pikku knowledge plan progress` is clean. What you wrote is what it is measured against.\n", "pikku-auth/references/better-auth.md": "# 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## 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-meta` 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| read credential values and who holds them | `admin:credentials:read` |\n| set and delete credentials | `admin:credentials:manage` |\n| view declared scopes, roles, and who holds them | `admin:scopes:read` |\n| create roles, change their scopes, grant them | `admin:scopes:manage` |\n| read the audit trail | `admin:audit:read` |\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 read: { description: 'Read credential values and who holds them' },\n manage: { description: 'Set and delete credentials' },\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 scopes: {\n description: 'Authorization management',\n scopes: {\n read: {\n description: 'View declared scopes, roles, and who holds them',\n },\n manage: {\n description:\n 'Create and delete roles, change their scopes, and grant roles to users',\n },\n },\n },\n audit: {\n description: 'The audit trail',\n scopes: {\n read: {\n description:\n 'Read the audit trail — every recorded action, and which user took it',\n },\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### Do not wire better-auth's `admin()` plugin\n\nEvery capability in the table is pikku's own, gated by the scope next to it and\nimplemented against better-auth's internal adapter — `createAuthUser`,\n`setAuthUserPassword`, `setAuthUserBanned`, `deleteAuthUser` and\n`revokeAuthUserSessions`, all exported from `@pikku/better-auth`.\n\n`admin()` would add a second gate on a `user.role` column that pikku otherwise\nignores, which means maintaining two grant systems that have to agree — and the\ncolumn loses, since the scope store is what the rest of the framework reads.\nPikku used to project scopes onto it for exactly that reason; dropping the\nplugin dropped the projection with it.\n\nBanning is the one capability with a schema requirement, and it has its own\nsmall plugin:\n\n```typescript\nimport { pikkuBan } from '@pikku/better-auth'\n\nbetterAuth({ plugins: [pikkuBan()] })\n```\n\n`pikkuBan()` adds `banned`, `banReason` and `banExpires` to `user` and refuses to\ncreate a session for a banned user, lapsing an expired ban as it goes. It makes\nno authorization decision — who may ban is decided by `admin:users:ban` — so it\nnever needs to know about scopes or roles.\n\n### The plugins `@pikku/better-auth` ships\n\nFive, all imported from the package root and passed to `betterAuth({ plugins })`\nlike any other. None is automatic — an app wires the ones it needs.\n\n| Plugin | Plugin `id` | Adds | Use it when |\n| ------------------------ | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------- |\n| `pikkuBan()` | `pikku-ban` | `user.banned/banReason/banExpires` | You ban users (the schema + enforcement half of the above) |\n| `pikkuActor()` | `actor` | `POST /sign-in/actor`, `user.actor` | Scenarios or a dev switcher sign in as a persona |\n| `pikkuCredentialOAuth()` | `credential-oauth` | `POST /credential-oauth/link`, `/credential-oauth/callback/:providerId` | An app links OAuth2 **API credentials** for a user |\n| `pikkuDelegatedAuth()` | `delegated-auth` | `POST /sign-in/delegated` | An imported upstream API is the system of record for identity |\n| `pikkuFabric()` | `fabric` | `POST /sign-in/fabric` | A Fabric-deployed app lets a control-plane operator in |\n\nEvery one carries a `pikku` prefix, because a `plugins: [...]` array mixes these\nwith better-auth's own and a bare `actor()` next to `organization()` says\nnothing about where it came from. The unprefixed names — `ban`, `actor`,\n`credentialOAuth`, `delegatedAuth`, `fabric` — are still exported as deprecated\naliases, so existing apps keep working.\n\nThe plugin's `id` is what better-auth stores; the **export name** is what the\ninspector reads off your `plugins` array and what generated metadata is keyed\nby, so the two differ for every one of them.\n\n#### `pikkuCredentialOAuth()` — link API credentials, not identities\n\n```typescript\npikkuCredentialOAuth({\n config: [\n {\n providerId: 'github',\n type: 'wire',\n clientId,\n clientSecret,\n authorizationUrl,\n tokenUrl,\n scopes: ['repo'],\n },\n { providerId: 'slack', type: 'singleton' /* … */ },\n ],\n scopeService,\n logger,\n})\n```\n\nWraps better-auth's `genericOAuth` to keep its token exchange and refresh, and\nreplaces only the two identity-bound endpoints. `genericOAuth`'s own\n`/oauth2/link` models an identity **provider**: it demands a userinfo response\nand refuses to link when the provider's email differs from the user's. A\ncredential is not an identity — most credential providers expose only\n`/authorize` and `/token` — so the account row is keyed on _whose_ credential it\nis (`accountId` = the linking user's id), making `(providerId, userId)` unique\nby construction. Tokens land in better-auth's `account` table, so\n`auth.api.getAccessToken()` refreshes them on read.\n\n`type` decides the blast radius:\n\n- **`wire`** — every user links their own, and the credential is read on\n the wire that runs as them. Signed in is enough.\n- **`singleton`** — one token the whole app shares, owned by a reserved\n `pikku-platform` user row created on demand. Rebinding it changes the\n credential for _everyone_, so it is gated on `admin:credentials:link` (or the\n `admin` root above it), and **fails closed** with no `ScopeService`. Override\n the whole gate with `canLinkSingleton`.\n\nAn undeclared `providerId` is a 404; an anonymous caller a 401; a refused\nsingleton a 403 that leaves no platform user behind.\n\n#### `pikkuDelegatedAuth()` — the upstream API is the identity provider\n\n```typescript\npikkuDelegatedAuth({\n authenticate: async ({ email, password, apiKey }) => upstream.login(...),\n storeCredential: (userId, identity) =>\n credentialService.set('acme', identity.credential, userId),\n defaultRole: 'member',\n mapRole: (upstreamRole) => ROLE_MAP[upstreamRole],\n scopeService,\n logger,\n})\n```\n\n`POST /sign-in/delegated` forwards the credentials the user already has to\n`authenticate`. On success it JIT-provisions a real user row (email-keyed and\n`emailVerified` — the upstream just verified them), links it via an `account`\nrow (`providerId: 'delegated'`, `accountId: externalId`), persists the upstream\ntoken **before** minting the session, and returns a normal session cookie.\nPasswords are never stored. Exactly one upstream per app: additional imported\nAPIs are linked integrations (`credentialOAuth`), not extra login methods.\n\nA resolved role is granted through the `ScopeService` as a pikku role, so it\nlands in `pikku_user_role` rather than on a column. A role the app never\ndefined is a provisioning gap, not a sign-in failure — the grant is dropped with\na warning and the user still gets in.\n\n`storeCredential` failing, by contrast, **fails the sign-in**: every proxied\ncall would be dead anyway.\n\n#### `pikkuFabric()` — control-plane operator sign-in\n\n```typescript\npikkuFabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY, scopeService, logger })\n```\n\n`POST /sign-in/fabric` verifies a short-lived RS256 token that the Fabric\ncontrol plane signed for an operator session, then signs them into a synthetic\n`fabric-<id>@fabric.internal` row holding the `admin` scope. Asymmetric on\npurpose: the app holds only the public key, so it can never forge an operator\nlogin, and the same `FABRIC_AUTH_PUBLIC_KEY` is distributed to every stage with\nno per-environment secret. A missing or empty key disables the endpoint, and a\ntoken whose `purpose` claim is not `fabric-admin` is rejected. Without a\n`ScopeService` the operator signs in holding nothing.\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/function'\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### Actor sign-in (`actor` plugin)\n\nA different thing from dev quick login, and the one to reach for when \"sign in as\nsomeone\" means **a particular kind of user** rather than one fixed admin.\nRegister it explicitly — it is not automatic:\n\n```typescript\nimport { ACTOR_SIGN_IN_OPT_IN_ENV, pikkuActor } from '@pikku/better-auth'\n\nplugins: [\n pikkuActor({\n secret: SCENARIO_ACTOR_SECRET,\n allowSignIn: await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV),\n }),\n]\n```\n\n`POST ${basePath}/sign-in/actor` `{ email, secret, name? }` → 200 + the normal\nsession cookie. The plugin's `secret` is the **root**, and it may be a (possibly\nasync) function so it can come off the secrets service instead of a captured\nvalue.\n\n**What a caller presents is not the root.** It is\n`deriveActorSecret(root, email)` from `@pikku/core/services` — HKDF-expanded\nHMAC-SHA256 over the lowercased address. The endpoint re-derives the expected\nvalue for the address being signed in as and compares, so a credential minted\nfor one persona is refused for every other, and the root itself is never a valid\ncredential. A root under 32 characters refuses the endpoint outright rather than\nderiving weak credentials from it (the server log names the problem; the client\nis not told which). Callers rarely derive by hand — `pikku dev` mints one per\npersona into `VITE_DEV_ACTOR_SECRETS` for the browser switcher, `pikku persona\nsecret <id>` mints them for a run, and the two `PersonaSignIn` implementations\nderive on the fly.\n\n**Which command is running decides whether it works, not whether a secret is\nset.** `pikku dev` sets `PIKKU_DEV_ACTOR_SIGN_IN` and mints an ephemeral\n`SCENARIO_ACTOR_SECRET` for the run, so local development needs no configuration\nat all. Everywhere else the endpoint refuses (`Actor sign-in is disabled outside\n\\`pikku dev\\``) — `pikku serve` clears the marker outright, so a secret that\nleaked into a production environment enables nothing and gets a warning naming\nitself instead.\n\nA stage that genuinely must run scenarios opts in on purpose, with\n`PIKKU_ALLOW_ACTOR_SIGN_IN=passwordless-actor-sign-in`. Any other value is\nignored and warned about, so the hatch cannot be opened by copying a `true` from\nthe line above.\n\n**Pass it in on any runtime without a populated `process.env`.** The gate reads\nthe environment by default, which is enough for Node but not for a Worker: there\nthe opt-in arrives as a binding and reaches user code through the variables\nservice, so a gate left to `process.env` stays shut on exactly the stages a\ndeployment targets. `allowSignIn` takes the value the caller already read —\n`await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV)` — and is checked against the same\nliteral, near-miss warning included. It is deliberately a value and not a flag:\nwhat opens the gate is still something the deployment set and an operator can\nread back out of it, never something compiled into the bundle. A value passed\nhere is the one consulted, so the environment cannot quietly override what the\nstage was configured with.\n\n**Signing in and provisioning are separate powers.** An unknown address becomes\nan `actor: true` row only under `pikku dev`. With the opt-in set, a stage signs\nin as the personas the deployment provisioned when it started and refuses\neverything else (`No actor account exists for that address`), so holding the\nsecret on such a stage does not let anyone invent identities. Those rows are\nwritten by the fabric plugin when an operator asks to act as an address the\nstage has no account for, so provisioning needs no actor secret and works on a\nstage whose endpoint is shut.\n\n**`SCENARIO_ACTOR_SECRET` is a credential as powerful as the most privileged\npersona.** Provisioning grants declared roles to actor accounts, so an\n`admin` persona is an actor holding real admin — anyone with the secret _and_ the\nopt-in can take a session as one. Do not treat \"actors only\" as a licence to open\nthe hatch in production.\n\nWithin that boundary, three properties bound the damage:\n\n- **It only ever signs in actors.** The plugin adds a `user.actor` boolean\n column; an email matching a row without it is refused with `User is not an\nactor`. So the secret cannot take over a **real user's** account — the blast\n radius is the actor accounts and whatever roles they were granted.\n- **Unknown emails are created only under `pikku dev`**, flagged `actor: true`,\n so a local scenario declaring a new persona needs no seed step. Anywhere else\n the account has to have been provisioned at boot first.\n- **A credential is bound to one address**, so a leaked one is one synthetic\n account rather than the whole actor population; only the root is worth the\n paragraph above. The comparison is constant-time and length-hiding, so a wrong\n credential leaks neither the length nor a prefix of the right one.\n\nThis is the endpoint `pikku scenario` signs its actors in through, and the one\nthe frontend dev switcher posts to — see `pikku-scenario` for declaring the\nactors and `pikku-react` for `useDevActors()`.\n\n### Provisioning personas\n\nAnywhere but `pikku dev`, the accounts have to exist before anyone signs in. The\nstage creates them itself, from the personas you hand `pikkuFabric`:\n\n```ts\nimport { pikkuFabric } from '@pikku/better-auth'\nimport {\n personaConfigs,\n personaEnvironments,\n} from '#pikku/pikku-personas.gen.js'\n\npikkuFabric({\n publicKey,\n audience,\n scopeService,\n personas: {\n personas: personaConfigs,\n environments: personaEnvironments,\n },\n})\n```\n\nThere is nothing else to call and nothing to schedule. The plugin's operator\nendpoint resolves the address the caller wants to act as; a miss provisions the\ndeclaration and looks again. On a stage that already holds the persona that is\none query, and the pass only runs when there is genuinely something absent to\ncreate.\n\n**Do not reach for `pikkuServerLifecycle`'s `afterStart` for this.** That hook is\ninvoked by `pikku serve` and `pikku dev` and by nothing else — no deploy runtime\ncalls it — so a stage on Workers or a serverless target that provisioned from\n`afterStart` provisioned nothing, and every persona signed in holding no roles.\n\nProvisioning runs where the database already is, which is the point: the CLI has\nno connection to a deployed environment's database — it resolves one from the\nlocal project config — so a `pikku persona sync staging` that wrote rows would\nwrite them to whatever database the checkout happened to point at. `pikku persona\nsync <environment>` still exists, and reports who that environment will provision\nand why anyone was skipped, which is what you run _before_ the deploy.\n\nIt creates missing accounts as `actor: true`, applies the roles each persona\ndeclares, and is additive — it never revokes. `PIKKU_ENV` (or an explicit\n`environment`) selects who is eligible, through the same rule that decides who\nmay run there; an address already held by a real, non-actor user throws rather\nthan being granted the persona's roles.\n\n**Deleting a persona does not delete its account.** Being additive leaves a hole:\nthe account keeps every role it was granted, and the actor endpoint authenticates\non the `actor` column alone without consulting the declaration — so an `admin`\npersona nobody declares any more is still a live way in wherever that endpoint is\nopen. By default provisioning warns about those accounts and changes nothing.\n`orphans: 'ban'` shuts them:\n\n```ts\npikkuFabric({\n publicKey,\n audience,\n scopeService,\n personas: {\n personas: personaConfigs,\n environments: personaEnvironments,\n orphans: 'ban',\n },\n})\n```\n\nIt writes the same `banned` column the console's ban RPC writes (so it needs the\n`pikkuBan()` plugin wired, and says so if it isn't), revokes the account's sessions,\nand leaves the row, its grants and its history intact — provisioning lifts the\nban again by itself if the persona comes back. Deleting is deliberately not\noffered: an actor row is referenced by whatever those scenarios did while it\nexisted.\n\n`report` is the default because a rolling deploy runs the new replica's\nprovisioning while the old replica is still serving, so for the length of that\noverlap \"no persona claims this\" is a statement about the newer declaration only.\nA persona pinned to another environment counts as unclaimed here — it has no\nbusiness holding a signable account in an environment its own rule refuses it.\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\n---\n\n## Post-signup side effects\n\nAnything that must happen after a user signs up — a welcome email, seeding a first\nrow, creating a personal organization — goes in `databaseHooks.user.create.after`\ninside the `betterAuth({...})` config. Never a custom signup RPC that writes the\nuser itself: better-auth owns the `user` table, and a second write path desyncs it\nfrom the session bridge.\n\n## Two-factor (2FA / MFA)\n\nTOTP authenticator apps, email/SMS OTP, backup codes and trusted devices are all\nbetter-auth's `twoFactor()` plugin. Never hand-roll TOTP or OTP.\n\n1. **Enable + migrate** — add `twoFactor({ issuer: '<AppName>' })` to the `plugins`\n array in the `pikkuBetterAuth` factory, then generate its schema and apply it as a\n migration like any other table change. Run the CLI at the version of better-auth the\n project actually has installed (`npx @better-auth/cli@<that version> generate`) — a\n bare `npx @better-auth/cli` resolves the latest release, which can emit a schema for\n a library you are not running. `twoFactorSecret` lands on `user`.\n2. **Client** — add `twoFactorClient({ onTwoFactorRedirect() { /* go to /2fa */ } })`\n to `createAuthClient({ plugins: [...] })`, and expose thin wrappers\n (`enable2FA`, `verifyTotp`, `disable2FA`, …) rather than leaking the raw\n `authClient`, same as every other auth call.\n3. **OTP delivery** — `otpOptions.sendOTP` goes through the injected email service\n and a rendered template, not a raw `sendEmail`. Set `storeOTP: 'encrypted'`.\n4. **Sign-in flow** — the challenge is raised on the three CREDENTIAL endpoints:\n `signIn.email`, `signIn.username` and `signIn.phoneNumber`. Check\n `context.data.twoFactorRedirect` in `onSuccess`; if true, route to a `/2fa` page\n and verify via `verifyTotp`/`verifyOtp`/`verifyBackupCode` (`trustDevice: true`\n for a 30-day trusted device). The response also carries `twoFactorMethods`\n (`'totp'` only once that user has a verified secret, `'otp'` whenever\n `otpOptions.sendOTP` is configured) — render the choice from it rather than\n assuming TOTP. The session cookie is only created after verification: the\n credential handler's session is deleted while the challenge is in flight, so a\n hook reading `ctx.context.newSession` after sign-in must null-check it.\n5. **UI** — a QR rendered from `data.totpURI` plus the `data.backupCodes` list.\n Enabling, disabling and regenerating backup codes all require the user's\n password.\n\nTOTP secrets and backup codes are encrypted at rest with the auth secret, and\n`/two-factor/*` is rate-limited (3/10s) out of the box.\n\n**2FA gates credential sign-in only.** Magic link, email OTP and OAuth are not\nmatched by the plugin's hook, so a user with 2FA enabled who signs in through one\nof them is NOT challenged. If every route into the app must be gated, either do not\noffer the passwordless ones to 2FA users or add your own check — enabling the\nplugin does not do it.\n\n## Security hardening\n\nThe `pikkuBetterAuth` factory — where `betterAuth({...})` is built — is the one\nplace to harden. Everything below is a `betterAuth` option, not a pikku one.\n\n- **Secret** — `BETTER_AUTH_SECRET` comes from the injected secrets service\n (`await secrets.getSecret('BETTER_AUTH_SECRET')`), never `process.env`, a\n literal, or a fallback default. 32+ chars, high entropy\n (`openssl rand -base64 32`). Better Auth rejects placeholder secrets in\n production.\n- **Trusted origins** — the `baseURL` origin is auto-trusted, so a single-domain\n app serving its API same-origin needs nothing. Add `trustedOrigins` (or a\n comma-separated `BETTER_AUTH_TRUSTED_ORIGINS` variable; wildcards like\n `*.example.com` allowed) ONLY when the browser origin differs from the API\n origin — embedded, preview, or custom-domain deployments. An untrusted\n `callbackURL`/`redirectTo`/`origin` is a 403.\n- **CSRF** — keep it on (`advanced.disableCSRFCheck: false`, the default). A\n proxy in front of the app must preserve the `/api/auth/*` prefix so origin\n checks still work; do not disable the check to \"fix\" a redirect.\n- **Rate limiting** — on by default in production (100/10s global, 3/10s on\n sign-in/up/change-password). `storage: 'memory'` resets on restart, so a\n deployed app wants `storage: 'database'`. Tighten sensitive routes with\n `customRules`, e.g. `'/sign-in/email': { window: 60, max: 5 }` — the key is matched\n against the path with the base path ALREADY STRIPPED, so a rule written as\n `'/api/auth/sign-in/email'` matches nothing and silently leaves the route on the\n default.\n- **Cookies & sessions** — `httpOnly`, `sameSite: 'lax'` and `path: '/'` are\n unconditional, but `secure` and the `__Secure-` name prefix are NOT: they follow a\n `baseURL` on `https://` (or production, or an explicit\n `advanced.useSecureCookies: true`). A deployment whose TLS terminates at a proxy\n and passes an `http://` baseURL through therefore ships session cookies with no\n `secure` flag — set `useSecureCookies` there rather than assuming. Defaults are\n `session.expiresIn` 7d and\n `updateAge` 1d. Add `freshAge` for sensitive actions, and\n `cookieCache: { strategy: 'jwe' }` if the session carries sensitive data — see\n the cookieCache section above, which you want enabled regardless. Only enable\n `crossSubDomainCookies` if auth is genuinely shared across subdomains.\n- **OAuth tokens** — set `account.encryptOAuthTokens: true` (AES-256-GCM) if you\n store provider tokens to call their APIs later.\n- **Audit** — drive auth events from `databaseHooks` (`session.create.after`,\n `user.update.after` for email changes, `account.create.after` for links) into\n whatever audit service the app injects, never a bespoke audit table wired into\n a function body. Returning `false` from a `before` hook blocks the operation.\n- **Background tasks** — on a serverless target, hand genuinely disposable work\n (analytics, logging) to `advanced.backgroundTasks.handler` → `ctx.waitUntil(promise)`\n so it does not delay the response. Not mail: the default handler is\n `p.catch(() => {})`, so anything that must actually arrive — an invitation, a\n password reset — is lost without a trace if the platform reaps the request first.\n Better Auth sends its own through `runInBackgroundOrAwait`, which awaits when no\n handler is configured; do the same for yours.\n- **Enumeration** — handled already (generic \"Invalid credentials\", dummy work on\n unknown users). Keep your own error copy generic too; never leak \"user not\n found\".\n", "pikku-auth/references/jose.md": "# Pikku Jose (JWT Service)\n\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-services`.\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-auth/references/machine-auth.md": "# 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\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-auth/references/permissions.md": "# Pikku Permissions\n\n## ⛔ FIRST: is the caller a machine with a token? ⛔\n\n**Then this is NOT a permissions problem.** Resolve the token in `addHTTPMiddleware('*')` middleware that calls `setSession`, make the function a `pikkuFunc`, and gate it with `scopes`. A `permissions` check that verifies a bearer token and returns `true` is authentication wearing an authorization hat — and it leaves the function sessionless, so every body still has to work out who called it. See `references/machine-auth.md`. The only exception is a bootstrap endpoint whose caller has no identity yet (a shared-secret registration, a login): that one is sessionless and declares its gate here.\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\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/auth'\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/auth'\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/auth'\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/scopes'\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(\n data.raw,\n http.request.header('stripe-signature'),\n secret\n )\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/auth'\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-auth/references/sessions.md": "# Pikku Security (Authentication & Sessions)\n\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/middleware'\nimport { addHTTPMiddleware } from '#pikku/middleware'\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/auth'\n\nexport const isAuthenticated = pikkuAuth(\n async (_services, session) => !!session\n)\nexport const isVerified = pikkuAuth(\n async (_services, session) => !!session?.emailVerified\n)\n\n// wirings/auth.wiring.ts\nimport { authCookie } from '#pikku/middleware'\nimport { addHTTPMiddleware } from '#pikku/middleware'\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-auth/SKILL.md": "---\nname: pikku-auth\ndescription: >-\n Use for anything about identity in a Pikku app — authenticating a caller (login, logout,\n sessions, cookies, bearer tokens, API keys, JWT, OAuth/social providers, MFA, Better Auth) and\n authorizing one (pikkuPermission, pikkuAuth, scopes, defineScope, global permissions). Covers\n which of the two a problem actually is, the built-in auth strategies, machine-to-machine auth\n and `pikku login`, and the JWT service. TRIGGER when: user asks about login, logout, session,\n cookie auth, bearer tokens, API keys, JWT, Better Auth, social providers, restricting who may\n call a function, resource ownership, roles, scopes, or hits MissingScopeError or\n InvalidSessionError. DO NOT TRIGGER when: user asks about middleware mechanics with no identity\n involved (use pikku-middleware) or about secrets and env vars (use pikku-services).\ninstallGroups: [core]\n---\n\n# Pikku Auth\n\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: which of\nthe two problems you actually have, and what goes wrong in each.\n\n## First: authentication or authorization?\n\nThey are separate gates, and confusing them is the most common mistake in a\nPikku app.\n\n**Authentication** answers _who is calling_. It happens in middleware, before the\nfunction runs, and ends in a call to `setSession`.\n\n**Authorization** answers _may this caller do this_. It is declared on the\nfunction — `scopes` and `permissions` — never checked inside its body.\n\nA `permissions` entry that verifies a bearer token and returns `true` is\nauthentication wearing an authorization hat: it leaves the function sessionless,\nso every body still has to work out who called it. Resolve the credential in\nmiddleware instead.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Restricting who may call a function — ownership, roles, scopes | `references/permissions.md` |\n| Reading or setting the session, or wiring `authBearer`/`authCookie`/`authAPIKey` | `references/sessions.md` |\n| Standing up user sign-in — OAuth, email+password, MFA, organizations | `references/better-auth.md` |\n| Authenticating a CLI, agent, sandbox or worker — API keys, `pikku login` | `references/machine-auth.md` |\n| Configuring the JWT service, or rotating a signing secret | `references/jose.md` |\n\n## All authentication goes through `@pikku/better-auth`\n\nThere is no second auth story. A hand-rolled user table, a bespoke password\nhash, a custom OAuth dance — all of them are the wrong answer, and the console\nwill not work against them. `references/better-auth.md` has the setup; the\nbuilt-in strategies in `references/sessions.md` are how a resolved credential\nbecomes a Pikku session, not a replacement for it.\n\n## The three gates\n\nAuthorization is three independent gates, all of which must pass, in this order:\n\n1. **Scopes** (`scopes`) — AND'd, checked before input validation, fails closed.\n2. **Global permissions** (`addGlobalPermission`) — AND'd, an app-wide baseline.\n3. **The function's own `permissions`** — OR'd groups of AND'd entries.\n\nThey are independent: a broad global gate can never satisfy a function's own\nrequirement, and a scope can only ever narrow access, never grant it.\n\n## What NOT to do\n\n- **Do not check authorization inside `func`.** The `permissions` field is\n visible to the inspector; a check in the body is invisible to `pikku info\n permissions` and to an audit. The one sanctioned exception is\n `permissionsInBody`, which is purely declarative — see\n `references/permissions.md`.\n- **Do not write an \"is signed in\" permission.** `auth: true` (the default on\n `pikkuFunc`) already requires the session. A checker returning `!!session`\n gates nothing.\n- **Do not put `scopes` on a `pikkuSessionlessFunc`.** Scopes fail closed and an\n anonymous caller holds none, so it would reject every caller it exists to\n serve. Gate those with `permissions`, which receive an optional session.\n- **Do not expect the built-in strategies to authenticate a non-HTTP caller.**\n `authBearer`, `authCookie` and `authAPIKey` all step aside when there is no\n HTTP request — a queue job, a scheduled task or a channel message needs its\n session set another way.\n- **Do not share one header between the human and machine paths.**\n `Authorization: Bearer` is the human session; `x-api-key` is the machine key.\n Merging them reintroduces the ambiguity the split exists to remove.\n- **Do not reach for wire-, tag- or route-level permissions.** They were removed\n in #972. Permissions live on the function, plus the optional global gate; tags\n are organizational only.\n", "pikku-blueprint-to-fabric/scripts/inventory.mjs": "#!/usr/bin/env node\n// Emit the Pikku implementation inventory for a .knowledge/ blueprint.\n//\n// node inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]\n//\n// Answers \"what will actually be built?\" BEFORE any code exists: every pikkuFunc,\n// permission, scheduler, queue worker, workflow, HTTP wiring, event channel,\n// table and scenario — plus, crucially, what is BLOCKED by an unresolved decision\n// and what is deliberately NOT built.\n//\n// This is a projection of the blueprint, not a plan you write by hand. It is\n// derived, so it stays honest: if it says 187 functions, the blueprint says 187.\n\nimport { readFileSync, existsSync } from 'node:fs'\nimport { join } from 'node:path'\n\nconst dir = process.argv[2]\nconst asJson = process.argv.includes('--json')\nconst domainArg = process.argv.includes('--domain')\n ? process.argv[process.argv.indexOf('--domain') + 1]\n : null\n\nif (!dir) {\n console.error('usage: inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]')\n process.exit(2)\n}\n\n// The four files pikku-software-archaeology's schema marks `x-optional` — an\n// API-only app really has no frontend. Every other file is REQUIRED, and a\n// missing or unparseable one is fatal rather than an empty list: this script's\n// whole claim is that its counts come from the blueprint, so silently reporting\n// \"0 commands\" for a directory that isn't a blueprint tells the exact lie the\n// inventory exists to prevent.\nconst OPTIONAL = new Set([\n 'interfaces.json',\n 'frontend.json',\n 'frontend-routes.json',\n 'frontend-components.json',\n])\n\nconst fatal = (msg) => {\n console.error(`inventory: ${msg}`)\n process.exit(1)\n}\n\nif (!existsSync(dir)) fatal(`no such directory: ${dir}`)\n\nconst read = (f) => {\n const p = join(dir, f)\n if (!existsSync(p)) {\n if (OPTIONAL.has(f)) return null\n fatal(\n `${dir} is missing ${f}. Run the archaeology validator first — ` +\n `an incomplete blueprint gives a count that reads as complete.`\n )\n }\n try {\n return JSON.parse(readFileSync(p, 'utf-8'))\n } catch (e) {\n fatal(`${f} is not valid JSON: ${e.message}`)\n }\n}\n\n/** A required file that parsed but carries the wrong shape is the same lie. */\nconst list = (f, key) => {\n const doc = read(f)\n if (doc === null) return []\n const rows = doc[key]\n if (rows === undefined) fatal(`${f} has no \"${key}\" array — blueprint shape changed?`)\n if (!Array.isArray(rows)) fatal(`${f}: \"${key}\" is ${typeof rows}, expected an array`)\n return rows\n}\n\nconst domains = list('domains.json', 'domains')\nconst entities = list('entities.json', 'entities')\nconst commands = list('commands.json', 'commands')\nconst queries = list('queries.json', 'queries')\nconst events = list('events.json', 'events')\nconst policies = list('policies.json', 'policies')\nconst workflows = list('workflows.json', 'workflows')\nconst apiDoc = read('api.json')\nconst api = apiDoc.surfaces ?? apiDoc.api ?? fatal('api.json has neither \"surfaces\" nor \"api\"')\nconst integrations = list('integrations.json', 'integrations')\nconst migration = read('migration.json')\nconst interfaces = read('interfaces.json')?.interfaces ?? []\nconst feComponents = read('frontend-components.json')?.components ?? []\nconst feRoutes = read('frontend-routes.json')?.routes ?? []\n\n// ---- the decisions gate -----------------------------------------------------\n// A concept named in an unresolved decision's blockedConcepts cannot be built.\n//\n// Answers are recorded OUTSIDE the blueprint — in the rebuild's own knowledge\n// base — because the blueprint is a record of the LEGACY app and must not be\n// rewritten as the rebuild proceeds. So pass the ones already answered:\n//\n// --resolved 1,2,3 (1-based index into migration.json.decisionsNeeded)\n//\n// Anything not listed is still blocking.\nconst allDecisions = migration.decisionsNeeded ?? []\nconst resolvedArg = process.argv.includes('--resolved')\n ? process.argv[process.argv.indexOf('--resolved') + 1]\n : ''\nconst resolvedIdx = new Set(\n resolvedArg.split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean)\n)\nconst decisions = allDecisions.filter((_, i) => !resolvedIdx.has(i + 1))\nconst resolved = allDecisions.filter((_, i) => resolvedIdx.has(i + 1))\nconst blocked = new Map() // concept -> question\nfor (const d of decisions) {\n for (const c of d.blockedConcepts ?? []) {\n if (!blocked.has(c)) blocked.set(c, d.question)\n }\n}\nconst dropped = migration.dropped ?? []\nconst droppedNames = new Set(\n dropped.map((d) => (d.path ?? d.file ?? '').split('/').pop())\n)\n\nconst isBlocked = (name, domain) => blocked.has(name) || blocked.has(domain)\n\n// ---- classification ---------------------------------------------------------\n// Cron-ish triggers. The blueprint records schedules in prose (\"daily at 05:00\",\n// \"every minute\"), because that is how the legacy code expressed them.\nconst CRON = /(cron|daily|nightly|hourly|every minute|every \\d|schedule|at \\d{2}:\\d{2})/i\nconst QUEUE = /(queue|consumer|perform_later|sidekiq|worker|async)/i\nconst WEBHOOK = /(webhook|POST \\/webhooks)/i\n// A trigger that fires off a row write is the legacy shape of an EVENT, not of a\n// workflow — a handler doing five unrelated things because there was no bus.\n// These become an event + its consumers, which is the structural upgrade.\nconst EVENT = /(after_commit|after_save|on create\\/update\\/destroy|observer|callback|mirror)/i\n\nconst classifyWorkflow = (w) => {\n const t = `${w.name} ${w.trigger ?? ''}`\n if (w.kind === 'system') {\n if (WEBHOOK.test(t)) return 'wireHTTP + command (webhook ingress)'\n if (CRON.test(t)) return 'wireScheduler'\n if (EVENT.test(t)) return 'event consumer (realtime/queue)'\n if (QUEUE.test(t)) return 'wireQueueWorker'\n return 'pikkuWorkflowFunc'\n }\n // User and admin journeys are NOT pikku workflows — they are sequences of\n // commands a person drives through the UI. They become scenarios.\n return 'scenario (user journey)'\n}\n\n// HTTP is warranted ONLY where the caller is a system we do not control and\n// cannot ask to speak RPC — i.e. it POSTs to a URL we publish, on its schedule.\n// In practice that means inbound webhooks.\n//\n// Everything else is RPC, including surfaces that look like they need HTTP:\n// - `auth: none` means anonymous, not external. Our own sign-up page calls it.\n// - a tokened/capability URL (a check-in link, a public share link) is a\n// first-party page reading a token; the page can call RPC like any other.\n// The caller being ours is what decides this, not the URL's shape or its auth.\nconst needsHttp = (s) => /webhook/i.test(s.path ?? '')\n\nconst byDomain = (arr) => {\n const m = new Map()\n for (const x of arr) {\n const d = x.domain ?? 'unassigned'\n if (!m.has(d)) m.set(d, [])\n m.get(d).push(x)\n }\n return m\n}\n\nconst cmdByDomain = byDomain(commands)\nconst qryByDomain = byDomain(queries)\nconst polByDomain = byDomain(policies)\nconst evtByDomain = byDomain(events)\nconst entByDomain = byDomain(entities)\n\nconst schedulers = workflows.filter((w) => classifyWorkflow(w) === 'wireScheduler')\nconst queueWorkers = workflows.filter((w) => classifyWorkflow(w) === 'wireQueueWorker')\nconst ingress = workflows.filter((w) => classifyWorkflow(w).startsWith('wireHTTP'))\nconst eventConsumers = workflows.filter((w) => classifyWorkflow(w) === 'event consumer (realtime/queue)')\nconst pikkuWorkflows = workflows.filter((w) => classifyWorkflow(w) === 'pikkuWorkflowFunc')\nconst journeys = workflows.filter((w) => classifyWorkflow(w) === 'scenario (user journey)')\nconst httpSurfaces = api.filter(needsHttp)\nconst scenarioCount = workflows.reduce((n, w) => n + (w.scenarios ?? []).length, 0)\n\nconst blockedCommands = commands.filter((c) => isBlocked(c.name, c.domain))\nconst blockedQueries = queries.filter((q) => isBlocked(q.name, q.domain))\nconst customLogic = feComponents.filter((c) => c.rebuild === 'custom-logic')\nconst cheapComponents = feComponents.filter((c) => c.rebuild !== 'custom-logic')\n\n// ---- addons -----------------------------------------------------------------\n// An addon is warranted where a capability is self-contained, reused across\n// domains, and has a clear service seam — which is what a `hard`/`critical`\n// integration is. Advisory: the call is the operator's.\nconst addonCandidates = integrations.filter(\n (i) => i.importance === 'critical' || i.replacementDifficulty === 'hard'\n)\n\nif (asJson) {\n console.log(\n JSON.stringify(\n {\n totals: {\n pikkuFunc: commands.length,\n pikkuSessionlessFuncReadonly: queries.length,\n pikkuPermission: policies.length,\n wireScheduler: schedulers.length,\n wireQueueWorker: queueWorkers.length,\n webhookIngress: ingress.length,\n pikkuWorkflowFunc: pikkuWorkflows.length,\n wireHTTP: httpSurfaces.length,\n eventChannels: events.length,\n tables: entities.length,\n scenarios: scenarioCount,\n blockedCommands: blockedCommands.length,\n blockedQueries: blockedQueries.length,\n droppedArtifacts: dropped.length,\n customLogicComponents: customLogic.length,\n },\n blocked: [...blocked.entries()].map(([concept, question]) => ({ concept, question })),\n },\n null,\n 2\n )\n )\n process.exit(0)\n}\n\n// ---- report -----------------------------------------------------------------\nconst out = []\nconst p = (s = '') => out.push(s)\n\np('# Pikku implementation inventory')\np('')\np(`Derived from \\`${dir}\\`. Every number below is a projection of the blueprint —`)\np('nothing here is estimated or invented.')\np('')\n\np('## Totals')\np('')\np('| Pikku artifact | Count | From |')\np('|---|---:|---|')\np(`| \\`pikkuFunc\\` (state-changing) | ${commands.length} | \\`commands.json\\` |`)\np(`| \\`pikkuSessionlessFunc\\` + \\`readonly: true\\` | ${queries.length} | \\`queries.json\\` |`)\np(`| \\`pikkuPermission\\` | ${policies.length} | \\`policies.json\\` |`)\np(`| \\`wireScheduler\\` | ${schedulers.length} | \\`workflows.json\\` (system + cron) |`)\np(`| \\`wireQueueWorker\\` | ${queueWorkers.length} | \\`workflows.json\\` (system + queue) |`)\np(`| webhook ingress (\\`wireHTTP\\` + command) | ${ingress.length} | \\`workflows.json\\` (system + webhook) |`)\np(`| \\`pikkuWorkflowFunc\\` | ${pikkuWorkflows.length} | \\`workflows.json\\` (system, multi-step) |`)\np(`| \\`wireHTTP\\` (fixed external URLs only) | ${httpSurfaces.length} of ${api.length} surfaces | \\`api.json\\` |`)\np(`| event channels (realtime/queue) | ${events.length} | \\`events.json\\` |`)\np(`| tables | ${entities.length} | \\`entities.json\\` |`)\np(`| scenarios | ${scenarioCount} | \\`workflows.json[].scenarios\\` |`)\np('')\np(`**Not built:** ${dropped.length} dropped artifacts · **Blocked:** ${blockedCommands.length + blockedQueries.length} concepts behind ${decisions.length} open decisions.`)\np('')\n\nif (resolved.length) {\n p('## Decisions already taken')\n p('')\n p('Answered at the gate and recorded in the rebuild\\'s knowledge base. Each is a')\n p('**deliberate behaviour change** and belongs in the parity report.')\n p('')\n for (const d of resolved) p(`- ${(d.blockedConcepts ?? []).join(', ')} — _${d.question.split('.')[0]}._`)\n p('')\n}\n\np('## Blocked by an open decision')\np('')\nif (decisions.length === 0) {\n p('_None — the gate is clear._')\n} else {\n p('These cannot be built until the question is answered. They block **their own')\n p('domains only** — every other slice proceeds.')\n p('')\n for (const d of decisions) {\n p(`- **${(d.blockedConcepts ?? []).join(', ') || '(unscoped)'}**`)\n p(` <br>${d.question}`)\n if (d.options) p(` <br>_Options:_ ${d.options.join(' · ')}`)\n }\n}\np('')\n\np('## Scheduled tasks (`wireScheduler`)')\np('')\np('| Workflow | Trigger |')\np('|---|---|')\nfor (const w of schedulers) p(`| ${w.name} | ${(w.trigger ?? '').replace(/\\|/g, '\\\\|')} |`)\np('')\n\nif (queueWorkers.length || ingress.length || pikkuWorkflows.length || eventConsumers.length) {\n p('## Other system wiring')\n p('')\n p('| Workflow | Becomes |')\n p('|---|---|')\n for (const w of [...ingress, ...queueWorkers, ...eventConsumers, ...pikkuWorkflows])\n p(`| ${w.name} | \\`${classifyWorkflow(w)}\\` |`)\n p('')\n}\n\np('## Journeys → scenarios')\np('')\np(`${journeys.length} user/admin journeys carrying ${scenarioCount} scenarios excavated from the`)\np('legacy test suite. These are **not** `pikkuWorkflowFunc`s — they are sequences a')\np('person drives through the UI, and they become scenario tests.')\np('')\np('| Journey | Kind | Scenarios |')\np('|---|---|---:|')\nfor (const w of journeys) p(`| ${w.name} | ${w.kind} | ${(w.scenarios ?? []).length} |`)\np('')\n\np('## Per domain')\np('')\np('| Domain | Tables | `pikkuFunc` | readonly | permissions | events | blocked |')\np('|---|---:|---:|---:|---:|---:|---:|')\nfor (const d of domains) {\n const n = d.name\n const b = (cmdByDomain.get(n) ?? []).filter((c) => isBlocked(c.name, n)).length +\n (qryByDomain.get(n) ?? []).filter((q) => isBlocked(q.name, n)).length\n p(\n `| ${n} | ${(entByDomain.get(n) ?? []).length} | ${(cmdByDomain.get(n) ?? []).length} | ${(qryByDomain.get(n) ?? []).length} | ${(polByDomain.get(n) ?? []).length} | ${(evtByDomain.get(n) ?? []).length} | ${b || ''} |`\n )\n}\np('')\n\np('## Addon candidates')\np('')\np('Advisory. A capability earns an addon when it is self-contained, reused across')\np('domains, and has a clear service seam — which is what a critical/hard-to-replace')\np('integration usually is. The call is yours.')\np('')\np('| Integration | Importance | Replace |')\np('|---|---|---|')\nfor (const i of addonCandidates)\n p(`| ${i.name} | ${i.importance ?? ''} | ${i.replacementDifficulty ?? ''} |`)\np('')\n\nif (feComponents.length) {\n p('## Frontend')\n p('')\n p(`- **${cheapComponents.length}** components are a cheap re-expression in Mantine (standard / composition / restyle).`)\n p(`- **${customLogic.length}** carry \\`custom-logic\\` and must be **ported**. This is the real frontend project.`)\n p(`- **${feRoutes.length}** routes → TanStack routes; their \\`dataFrom\\` names are already the function names above.`)\n p('')\n if (customLogic.length) {\n p('| custom-logic component | What must survive |')\n p('|---|---|')\n for (const c of customLogic)\n p(`| ${c.name} | ${(c.customLogic ?? '').replace(/\\|/g, '\\\\|').slice(0, 110)} |`)\n p('')\n }\n}\n\nif (interfaces.length) {\n p('## Interfaces')\n p('')\n p('| Channel | Audience | Status |')\n p('|---|---|---|')\n for (const i of interfaces) p(`| ${i.kind} | ${i.audience ?? ''} | ${i.status ?? ''} |`)\n p('')\n}\n\np('## Deliberately not built')\np('')\np('| Artifact | Why |')\np('|---|---|')\nfor (const d of dropped)\n p(`| \\`${d.path ?? d.file}\\` | ${(d.reason ?? '').replace(/\\|/g, '\\\\|').slice(0, 120)} |`)\np('')\n\nconsole.log(out.join('\\n'))\n", "pikku-blueprint-to-fabric/SKILL.md": "---\nname: pikku-blueprint-to-fabric\ndescription: 'Rebuild a legacy app as a Pikku Fabric app from a `.knowledge/` Product Blueprint (produced by pikku-software-archaeology). Covers the blueprint→Fabric mapping (domains→slices, commands/queries→pikkuFuncs, entities→SQLite migrations, policies→permissions, invariants→DB constraints, workflows→schedulers, frontend-routes→TanStack+Mantine), the decisions gate, and the parity report. TRIGGER when: a `.knowledge/` blueprint exists and the user wants to rebuild/port/recreate that app in Pikku or Fabric, or says \"rebuild this from the blueprint\". DO NOT TRIGGER when: no blueprint exists (run pikku-software-archaeology first), or the user wants a single new feature in an existing app (use pikku-build).'\ninstallGroups: [fabric]\nargument-hint: '<path to .knowledge/> [domain to slice next]'\n---\n\n# Blueprint → Fabric\n\n## Agent Operating Procedure\n\nUse this skill as an execution checklist, not reference material.\n\n1. **Validate the blueprint before you trust it.** Run the archaeology validator; `0 error(s)` or stop.\n2. **Clear the decisions gate.** Unresolved `decisionsNeeded` block the domains they touch. Ask; do not invent.\n3. **Build the schema first**, from `entities.json` — everything else hangs off it.\n4. **Then one vertical slice per domain, in dependency order.** Each slice ships functions + permissions + migrations + scenarios and verifies green before the next starts.\n5. **Verify with `pikku fabric validate --json`, then codegen and `tsc`** after every slice (Stage 8 has the exact commands). Never batch a whole app and verify at the end.\n6. **Write the parity report as you go**, not at the end — it is the deliverable that proves the rebuild is complete.\n\nThis skill is the **translation layer** only. For how Fabric itself works (SQLite/libSQL, `fabric.config.json`, deploy provider, project layout) read **pikku-fabric**. For everything else, delegate to the sibling skill named at each step.\n\n## The one idea\n\n**The blueprint is a plan, not a transcript.**\n\nA faithful port reproduces the legacy app's bugs, dead code, and drifted rules — and you will have spent months to arrive back where you started. The blueprint already separates the **product** (what the business meant) from the **accident** (what the code happened to do): that separation is `gaps.json`, `migration.json.dropped`, `invariants.enforcedBy: \"nothing\"`, and the `confidence` field. Using that separation is the entire reason the blueprint exists.\n\nSo:\n\n- `commands.json` / `queries.json` / `entities.json` / `policies.json` → **build these**.\n- `gaps.json` (`kind: bug` / `dead-code`) and `migration.json.dropped` → **do not build these.** They are the accident.\n- `invariants.json` with `enforcedBy: \"nothing\"` → **build these properly for the first time.** This is where a rebuild actually earns its cost.\n- `decisionsNeeded` → **ask.** These are the questions the legacy code never answered, and neither can you.\n\nIf you find yourself opening the legacy source to \"check how it did X\", stop. Either the blueprint says X (build that) or it doesn't (it's a decision — ask). Reading the old code is how its accidents get back in.\n\n## Stage 0 — Preflight\n\n```bash\nnode <archaeology-skill-dir>/scripts/validate.mjs <repo>/.knowledge # must print 0 error(s)\n```\n\nA blueprint with validation errors has dangling concept names, and concept names are the IDs this whole skill maps on. Fix it there, not here.\n\nThen read, in this order — **whole files, once**: `product.json` (what it is, and the terminology traps), `domains.json` (the slice list and the roll-ups), `migration.json` (what survives, what drops, what's undecided). These three tell you the shape of the job. Read the rest per-slice, not up front — `commands.json` at 180+ entries will drown you if you read it whole before you need it.\n\n### The terminology trap — do this before you name anything\n\n`product.json.terminology` and `migration.json.decisionsNeeded` frequently carry a **false friend**: a word the legacy code used that means something else to everyone else (a real case: `tenant` meaning *the seller's own market/legal entity*, not a customer org — where the customer org was `Company`).\n\nCarrying a false friend into the rebuild wastes the single cheapest opportunity you will ever have to fix it. Resolve the rename **before the first migration is written**, then apply the new name in table names, function names, and types. Add it to the parity report's glossary so the mapping stays legible to whoever compares old and new.\n\n## Stage 1 — The decisions gate (blocking)\n\nCollect every:\n\n- `migration.json.decisionsNeeded[]`\n- `gaps.json[]` where `kind: \"open-product-decision\"`\n- any concept with `confidence: \"low\"`\n\n**These block the domains they touch. They do not block the whole rebuild** — take them to the user grouped by domain, so unaffected slices proceed while decisions are pending.\n\nPresent each as a real question with the options the code implies and what each costs — not \"what should happen when a renewal fails?\" but \"the `unpaid` state exists and nothing can reach it; when a renewal payment fails, do we (a) lapse immediately, (b) grace period of N days, (c) suspend the public listing but keep the account? (c) is what the listing gate implies but nothing implements it.\"\n\n**Never resolve one by reading the legacy code.** If the code answered it, the archaeologist would not have raised it. Silence in the legacy code is the finding.\n\nRecord each answer in the parity report under **Decisions taken** with the date and who decided. This is the audit trail for behaviour that is *deliberately* not a port.\n\n## Stage 1.5 — Emit the implementation inventory (do this before any code)\n\n```bash\nnode <skill-dir>/scripts/inventory.mjs <repo>/.knowledge --resolved 1,2 > <repo>/.knowledge/implementation-inventory.md\n```\n\nThis projects the blueprint into **Pikku terms** — every `pikkuFunc`,\n`pikkuPermission`, `wireScheduler`, `wireQueueWorker`, webhook ingress, event\nchannel, table and scenario that will exist, per domain, plus what is **blocked**\nby an open decision and what is **deliberately not built**. `--resolved` takes the\n1-based indices of `decisionsNeeded` already answered at the gate.\n\nDo this **before** scaffolding, and show it to the user. Three reasons:\n\n1. **It makes the size real.** \"Rebuild the app\" is not a plan; \"187 functions, 61\n permissions, 12 scheduled tasks, 268 scenarios, 33 custom-logic components, and\n 14 concepts blocked behind 4 questions\" is one. Nobody can consent to the work\n until they can see it.\n2. **It is derived, so it cannot flatter you.** Every number is a projection of the\n blueprint. If it says 12 scheduled tasks, the blueprint found 12 cron jobs.\n3. **It exposes the ratio that matters.** A 223-surface `api.json` typically yields\n ~20 `wireHTTP` wirings; the rest is RPC. If your inventory says otherwise, you\n are about to transcribe the legacy router.\n\nThe classifier is a heuristic over `workflows.json` triggers and is **advisory** —\ncheck its calls. The distinctions it encodes are the ones that matter:\n\n- **system + cron → `wireScheduler`**; **system + webhook → ingress**;\n **system + queue → `wireQueueWorker`**.\n- **system + `after_commit`/callback → an event and its consumers**, NOT a\n workflow. That trigger is the legacy shape of an event: a handler doing five\n unrelated things because there was no bus. Splitting it is the upgrade.\n- **user/admin journeys → scenarios, NOT `pikkuWorkflowFunc`s.** A blueprint\n \"workflow\" is a *journey* — a sequence a person drives through the UI. A\n `pikkuWorkflowFunc` is durable multi-step orchestration. Conflating them produces\n a workflow engine driving form submissions, which is the most common way this\n mapping goes wrong.\n\nRe-run it per slice: the blocked count falls as decisions land, and it is the\ncheapest progress report you have.\n\n## Stage 2 — Scaffold\n\nClone the Fabric starter template, then do the post-clone cleanup **pikku-build** covers (README, package name, lockfile, leftover template artifacts) — a rebuild that ships the template's own name and readme is the first thing a reviewer notices. Set `projectId`, `production.branch` and the frontend entry in `fabric.config.json` per **pikku-fabric**.\n\nMap `architecture.json` onto Fabric honestly, and expect it to shrink:\n\n| Blueprint `architecture.json` | Fabric |\n|---|---|\n| API/web process (Puma, Express, …) | the Fabric worker — no component to build |\n| Worker process + queue | `wireQueueWorker` (**pikku-wiring**) |\n| Cron/scheduler component | `wireScheduler` (**pikku-wiring**) |\n| Reverse proxy, deploy tooling, process manager | **drop** — the platform does this |\n| Admin console (ActiveAdmin, Django admin, …) | `scaffold.console: true` first; only build screens for what it genuinely can't express |\n| Session/auth store | Better Auth (**pikku-auth**) |\n| Relational datastore | SQLite via libSQL/Kysely (**pikku-fabric**, **pikku-kysely**) |\n| Redis for cache/locks/queues | usually **nothing** — see the trap below |\n\n**The Redis trap.** Legacy apps use Redis for four unrelated jobs: queue backend (→ Fabric's queue), cache (→ usually delete; measure first), pub/sub (→ **pikku-realtime**), and **distributed locks**. That last one is the trap: a lock is nearly always a workaround for a missing database constraint (`invariants.json` will show the same rule with `enforcedBy: \"code-guard\"` and an `atRiskBecause`). Port the *invariant* to a constraint; do not port the lock. Re-implementing legacy locking on a new stack is how you carry a race condition across a rewrite.\n\n`architecture.json.deploymentConstraints` is the exception to \"drop the infrastructure\": entries there are constraints that must **survive** (raw-body ordering for webhook signatures, retry semantics an external caller depends on). Read them; they are cheap to lose and expensive to rediscover.\n\n## Stage 3 — Schema first\n\nBuild the whole schema from `entities.json` before writing functions: plain numbered `.sql` in `db/sqlite/`, applied with `pikku db migrate`, which regenerates the Kysely types. **Never hand-edit the generated `schema.gen.ts`.**\n\nCheck the numbering against what is already there — the starter template ships\nmigrations of its own (Better Auth's schema, the audit table, the auth plugins),\nand a colliding number applies in an order you did not intend.\n\n**Use semantic column types.** `BOOLEAN` types as a real `boolean`, `DATETIME`/`DATE`\nas a `Date`, `JSON` as a parsed object — the generated types and coercion follow from\nthe SQL. Writing `INTEGER` 0/1 flags or Unix-ms timestamps throws that away and you\nhand-coerce forever. `TEXT` + `CHECK` for a closed set is the highest-leverage choice\navailable: the constraint compiles into a **TypeScript union type**, so an invalid\nstate is a compile error rather than a runtime one.\n\nThe blueprint gives you `attributes`, `relationships`, `states`, `transitions`, `ownership`, `constraints`. It is a *domain* model, not the legacy DDL — you are not required to reproduce the old column layout, and usually shouldn't.\n\n### Legacy SQL → SQLite traps\n\n| Legacy | SQLite / Fabric | Why |\n|---|---|---|\n| `DECIMAL`/`NUMERIC` money, or a money library's `*_cents` | `INTEGER` minor units | SQLite has no exact decimal. Minor units are what the legacy money library stored anyway — keep the currency in its own column. **Never `REAL` for money.** |\n| `TIMESTAMP`/`DATETIME` | `DATETIME` | Types as a real `Date`. Do NOT store Unix ms in an `INTEGER` — you lose the typing and coerce by hand forever. |\n| `BOOLEAN` | `BOOLEAN` | Types as a real `boolean`. Do NOT store 0/1 in an `INTEGER`. |\n| `ENUM` | `TEXT` + `CHECK` | The one place to spend a `CHECK` — it is a closed set, and a typo'd state is exactly the bug class the blueprint keeps finding. |\n| `uuid`/`serial` PK | `INTEGER PRIMARY KEY AUTOINCREMENT`, or `TEXT` for a public id | **Look at `queries.json.scoping` first.** If the legacy app used a random public id (`puid`, slug) precisely so ids aren't enumerable, that is a *security property* — keep it. Silently switching to sequential integers un-fixes a fix. |\n| JSON column | `TEXT` + a typed parse | Fine, but if `entities.json` gives it real attributes, it wants columns. |\n| DB-level `CHECK` sprawl | app-level validation | Per **pikku-fabric** — *except* the invariants below. |\n\n### States and transitions\n\n`entities[].states` + `transitions` is a state machine the legacy app ran through a library (aasm, state_machine, …). **Do not port the library.** Store the state as `TEXT` + `CHECK`, and let each transition be the `pikkuFunc` that `commands.json` already names (`CancelMembership`, `RefundInvoice`).\n\nTwo traps the blueprint hands you for free:\n\n- **Unreachable states.** A state in `entities[].states` that no transition targets is either a dead declaration (drop it) or a `decisionsNeeded` (ask). Do not create an unreachable state in the new app just because the old one had it.\n- **Dead transitions.** `migration.json.dropped` may list a transition dropped for a typo (a real case: `partial_refunded` vs `partially_refunded`, which made multi-step partial refunds raise). Build the transition the product **means**, which is the one the state list supports — not the typo.\n\n## Stage 4 — Slices, in dependency order\n\nOne vertical slice per domain. **Order by inbound reference count, not by importance**: the domains everything else points at go first. Identity and the customer/account domain are almost always the base — every other domain's `ownership` and `scoping` mentions them.\n\nDerive the order mechanically: for each domain, count how many *other* domains' entities have a relationship into it. Build the most-referenced first. Then, among the rest, take the ones that carry the most invariants and events (`domains.json` roll-ups tell you) — that's where the product is, and you want it under test early.\n\nLeave for last: thin CRUD domains with no events and no invariants (content, downloads, media libraries). They're mechanical, and `migration.json` may well say the honest thing — that some shouldn't be rebuilt at all.\n\n### What one slice contains\n\n| Blueprint | Fabric artifact | Skill |\n|---|---|---|\n| `commands[]` | one `pikkuFunc` per command, one per file, `expose: true` | **pikku-concepts** |\n| `queries[]` | one `pikkuSessionlessFunc`, `readonly: true`, `expose: true` | **pikku-concepts** |\n| `commands[].preconditions` | guards in the function body, throwing typed errors | **pikku-fabric** hard rules |\n| `policies[]` | `pikkuPermission` on the function's `permissions:` field | **pikku-permissions** |\n| `queries[].scoping` | the permission + the query's `where` | **pikku-permissions**, **pikku-kysely** |\n| `events[]` | realtime topic / queue message | **pikku-realtime**, **pikku-wiring** |\n| `workflows[] kind: system` | `wireScheduler` | **pikku-wiring** |\n| `workflows[]` multi-step | `pikkuWorkflowFunc` + `*.steps.ts` | **pikku-workflow** |\n| `workflows[].scenarios[]` | scenario tests | **pikku-scenario** |\n| `api[]` | mostly **nothing** — see below | **pikku-wiring** |\n| `invariants[]` | DB constraints, in the migration | **pikku-fabric** |\n| `integrations[]` | services | **pikku-services** |\n\n### Names are the contract\n\n`commands.json`/`queries.json` names are already imperative domain-language `VerbNoun` — which is exactly `pikkuFunc` naming. **Carry them verbatim.** They are the IDs that tie the blueprint, the parity report, `frontend-routes.json.dataFrom`, and the generated RPC client together. Renaming `AssignMembershipToUser` to `assignMembership` because it reads better costs you the whole cross-reference and buys nothing.\n\nTwo exceptions: apply resolved false-friend renames (Stage 0), and apply any rename the user decided at the gate. Record both in the parity glossary.\n\nEvery function needs a real `description` — take it from the concept's `description`/`purpose`, which is already written in product language. Per **pikku-fabric**, `missing description` means the work isn't finished.\n\n### The API is not the contract\n\n`api.json` has one entry per legacy surface, and it is **evidence, not a spec**. Each entry's `mapsTo` names the command or query — build *that*, and let RPC be the transport (**pikku-fabric**: RPC first, `expose: true`).\n\nAdd `wireHTTP` only where the URL shape is a real external contract:\n\n- `auth: \"none\"` public pages that must keep their paths (SEO, printed links, QR codes)\n- **inbound webhooks** — the sender's URL is fixed (**pikku-wiring**)\n- surfaces `interfaces.json` marks as a genuine `openapi-rest` channel with external consumers\n\nA 200-surface `api.json` typically yields a handful of `wireHTTP` calls. If you're wiring HTTP for most of it, you're transcribing the legacy router.\n\n`api.json.auth` is still load-bearing: it's the per-surface answer that fills each function's `permissions:`. Where `auth` and `policies.json` disagree, the disagreement is a finding — check `gaps.json`, and if it's not there, raise it.\n\n### Invariants — where the rebuild earns its cost\n\nFor each `invariants[]` entry, look at `enforcedBy`:\n\n- `\"nothing\"` → **build it properly now.** This is the highest-value work in the whole rebuild: a rule the business believes it has and does not. It's typically a `UNIQUE`, a `CHECK`, a foreign key, or a transaction — cheap here, and the legacy app couldn't get to it because the enforcement had drifted somewhere unreachable.\n- `\"convention\"` or `\"code-guard\"` with an `atRiskBecause` → **move it down to the database** if it's expressible there. `atRiskBecause` usually describes a race the constraint eliminates outright.\n- `\"db-constraint\"` → carry it across. It already works.\n\nTwo patterns worth naming, because they recur:\n\n- **Read-then-write idempotency** (check `find_by(external_id:)`, then insert) on a **nullable, non-unique** column. The fix is a `UNIQUE` index and an upsert — not a port of the check.\n- **Application-held sequence numbers** (an invoice counter behind a distributed lock). Use a DB-level guarantee. If a legacy test for this is commented out (the blueprint flags this under `gaps.json`), write it for real in the new app — that's a scenario, and it's the one that would have caught it.\n\n### Events — make the implicit explicit\n\nMost `events[]` in a legacy blueprint carry `explicit: false`: there was no event bus, and the archaeologist reconstructed the event from a side-effect cluster (an email + a status flip + a counter bump in one handler). The `consumers` field lists what reacted.\n\nIn the rebuild these become real: publish the event (**pikku-realtime**) or enqueue it (**pikku-wiring**), and make each listed consumer its own subscriber. That is the structural upgrade — the handler stops doing five unrelated things, and adding a sixth consumer stops meaning editing the handler.\n\nTwo disciplines:\n\n- **Do not invent events.** The archaeologist applied a threshold (≥1 real consumer beyond the row write). If a CRUD fact isn't in `events.json`, it didn't earn an event; a state row that is only *read* later is state, not an event.\n- **`explicit: false` is a confidence marker.** These are reconstructions of intent. When one drives money or an external side effect, the parity report says it was reconstructed — the reviewer should confirm the consumer list is complete.\n\n### Policies — collapse the drift\n\n`policies[].enforcedAt` lists **every** legacy site enforcing the rule. Two or more entries usually means it drifted — same rule, subtly different versions. The blueprint often pairs it with a `gaps.json` `duplication` entry naming the drift.\n\nThe whole point is **one `pikkuPermission` per rule**, referenced from every function that needs it (**pikku-permissions**). When the `enforcedAt` versions genuinely disagree, that's a decision, not a merge — ask which is correct. Picking the one you read first silently ships a behaviour change.\n\nPer **pikku-fabric**: no auth checks in function bodies. If a policy resists expression as a permission, that's a signal it's a *business rule* (a precondition) rather than authorization — those live in the function body and throw typed errors.\n\n## Stage 5 — Scenarios from the blueprint's tests\n\n`workflows[].scenarios[]` entries carry `fromTest` — they were excavated from the legacy suite, which means **they are the legacy app's executable spec**, already in given/when/outcome shape. They map directly onto **pikku-scenario** actors and flows, and `product.json.actors` gives you the actor list.\n\nThis is the highest-leverage stage in the rebuild and the easiest to skip. A scenario ported from a legacy test is the only artifact that can tell you the new app *behaves* like the old one — parity of function names proves nothing.\n\nTwo rules:\n\n- A scenario whose legacy test was **commented out or broken** (`gaps.json` flags these) still gets written — it just isn't parity, it's new coverage. Note which in the parity report; often the disabled test is disabled *because* the behaviour was broken.\n- A workflow with **no scenarios** is a gap in the blueprint, not permission to skip testing. Flag it rather than inventing behaviour to test.\n\n## Stage 6 — Integrations\n\n`integrations[]` gives `direction`, `dataExchanged`, `importance`, `replacementDifficulty`, `envVars`.\n\n- `replacementDifficulty: \"hard\"` + `importance: \"critical\"` → **keep**, and put it behind a service (**pikku-services**). These are the load-bearing vendors; a rebuild is not the time to also swap them.\n- `\"trivial\"` → candidates for a platform-native equivalent, but only if the user wants it. Swapping a vendor mid-rebuild makes every failure ambiguous.\n- `envVars` → `defineVariable` / `defineSecret` (**pikku-services**). Per **pikku-fabric**: no `process.env`, ever.\n\n**Secrets in the blueprint are live secrets.** `gaps.json` security entries routinely name credentials hardcoded in the legacy source *and its committed history*. They must be **rotated**, not copied into the new app's secret store — and rotation is the legacy app's problem, today, independent of the rebuild. Say so; don't let the rebuild timeline become the remediation timeline.\n\n**Inbound webhooks deserve a real look.** They're the surfaces most likely to be carrying a `gaps.json` security entry (unverified signatures, disabled checks). Rebuild the verification properly (**pikku-wiring**), and if the reason it was disabled was a vendor that doesn't reliably sign, that's a `decisionsNeeded` — not something to replicate.\n\n## Stage 7 — Frontend\n\nOnly when `frontend*.json` is present. Target: TanStack Start + Mantine.\n\n`frontend.json` records the legacy stack as facts. **It is context, not a port target** — a bespoke Sass system, a server-rendered template stack, or a different component library all land on the same target. Read `designSystemConsistency` and `designFindings` to know what *not* to carry: findings are the drift (hardcoded colors, forked-per-locale pages, duplicated components), and the rebuild is the moment they cost nothing to drop.\n\n### Routes\n\n`frontend-routes[]` → TanStack routes. `path` and `purpose` carry over; `auth` becomes the route guard.\n\n**`dataFrom` is the payoff.** It lists query/command names — the *same* names as `queries.json`/`commands.json`, which are the same names as your `pikkuFunc`s, which are the same names in the generated client. So a route's data layer is mechanical: each `dataFrom` entry is a generated hook (**pikku-react**). If `dataFrom` contains a name that isn't a real function, the blueprint wasn't reconciled — go fix it there.\n\n### Components — the honest cost\n\n`frontend-components[].rebuild` is the only field that matters for planning:\n\n| `rebuild` | What to do |\n|---|---|\n| `mantine-standard` | Use the Mantine component. Do not port. |\n| `mantine-composition` | Compose from Mantine primitives. Do not port. |\n| `custom-style` | Normalize to Mantine + theme tokens. The divergence is the thing to drop. |\n| **`custom-logic`** | **Port the behaviour.** Read `customLogic` and `dependencies`. |\n\nThe first three are the bulk and they're cheap — they're a re-expression, not a migration. **`custom-logic` is the actual project**: the bespoke chart, the virtualized table, the map surface, the rich editor, the drag interaction. Each has real behaviour that must survive, and `customLogic` says what it is.\n\nTwo things to watch:\n\n- **Scope `custom-logic` explicitly, per component, before starting the frontend.** If a single component is thousands of lines (a map/finder surface is the classic), it is a project of its own and must be planned as one. \"It's just screens\" is how frontend rebuilds overrun.\n- **Forked twins.** `designFindings` often shows the same custom-logic surface duplicated (a finder and its near-identical sibling). Build it **once**, parameterized. That's a rebuild dividend — say so in the parity report.\n\n## Stage 8 — Verify\n\nPer slice, narrowest first:\n\n```bash\npikku fabric validate --json # structural: fix every error and warn\nyarn pikku all # codegen + version compliance\nyarn tsc --noEmit\n```\n\nThen run the slice's scenarios. All four green — validate, codegen, `tsc`, scenarios — is what \"slice done\" means; three of four is a slice you have not finished. **pikku-fabric** owns the loop and what each finding means.\n\nNever batch. A rebuild verified only at the end gives you an undifferentiated pile of failures with no bisect point, and the whole reason for slicing is that each slice is a checkpoint you can trust.\n\nNew functions with `expose: true` are versioned from the start — `pikku versions` / `pikku semver` (**pikku-meta**); you're establishing v1 contracts, not migrating them.\n\n## Stage 9 — The parity report (the deliverable)\n\nWrite `<repo>/.knowledge/parity-<domain>.md` per slice, as you finish it. This is a real output, not\nbookkeeping: it is the one place a human can answer \"is the rebuild done?\" without reading the\ndiff, because it is the only document that holds the blueprint and the new code side by side.\n\nPer domain:\n\n- **Built** — each command/query/event/policy → its function/file. The concept-name-as-ID makes this a table, not prose.\n- **Deliberately not built** — from `migration.json.dropped` and `gaps.json`, with the reason. *The most important section.* Without it, every dropped bug and orphan reads as a regression to whoever reviews.\n- **Decisions taken** — each gate answer, who decided, when. Behaviour that deliberately differs from the legacy app.\n- **Now enforced** — invariants that were `enforcedBy: \"nothing\"` and now have a constraint. The rebuild's actual dividend, in one list.\n- **Reconstructed** — anything from `confidence: low`/`medium` or `explicit: false` events. Flag for confirmation against production behaviour.\n- **Not verifiable from the blueprint** — what needs real data or a human (volume-dependent races, whether a legacy bug ever fired).\n- **Glossary** — legacy name → new name, for every rename including the false friends.\n\n## Red flags\n\n| Thought | Reality |\n|---|---|\n| \"Let me check how the old code did this\" | The blueprint says what it does. If it doesn't, it's a decision — ask. Reading legacy source is how its accidents get re-imported. |\n| \"I'll port the state machine library\" | Port the *states and transitions*. The library is implementation; `commands.json` already names every transition. |\n| \"The blueprint lists this state, so I'll create it\" | Check it's reachable. Unreachable states are a finding, not a spec. |\n| \"I'll wire HTTP for each `api.json` entry\" | You're transcribing the legacy router. RPC first; `wireHTTP` only for genuinely fixed external URLs. |\n| \"The old app didn't enforce it, so neither will I\" | `enforcedBy: \"nothing\"` is the highest-value work in the rebuild — the reason it's worth doing at all. |\n| \"I'll add events for the CRUD actions too\" | The archaeologist applied a consumer threshold. Not in `events.json` = didn't earn one. |\n| \"Two enforcement sites disagree; I'll use the first one\" | That's a silent behaviour change. Drift is a decision — ask which is correct. |\n| \"It's just screens, the frontend is quick\" | The `custom-logic` components are the project. Scope them individually before starting. |\n| \"I'll copy the secrets into the new secret store\" | Blueprint-exposed secrets are burned. Rotate. And the legacy app needs that today, regardless of the rebuild. |\n| \"I'll do the parity report at the end\" | You will not remember why you dropped things, and dropped-on-purpose will read as regression. |\n| \"Verify once it's all built\" | No bisect point. Verify per slice; that's what slices are for. |\n| \"The blueprint has a `low`-confidence entry, I'll build my best guess\" | That's inventing product. It's a gate question. |\n\n## Quick reference\n\n```bash\nnode <archaeology-skill>/scripts/validate.mjs <repo>/.knowledge # Stage 0 — must be 0 errors\n# Stage 1 — decisions gate: ask, don't invent\n# Stage 2 — clone starter template, then the post-clone cleanup (pikku-build)\n# Stage 3 — entities.json -> db/sqlite/NNNN-*.sql ; pikku db migrate\n# Stage 4..7 — one domain slice at a time, dependency order\npikku fabric validate --json\nyarn pikku all && yarn tsc --noEmit\n# Stage 9 — .knowledge/parity-<domain>.md per slice\n```\n\n## Relationship to the other skills\n\n```\nlegacy repo → pikku-software-archaeology → .knowledge/ blueprint\n └→ pikku-blueprint-to-fabric → Fabric app + parity-*.md\n```\n\n**pikku-software-archaeology** extracts the facts and validates them. **This skill** builds the\nthing, and emits `parity-*.md` so the rebuild can be reviewed against the blueprint rather than\nagainst the legacy code.\n\nFor Fabric mechanics — project layout, `fabric.config.json`, the validate loop, reading a deployed\nstage — use **pikku-fabric**. For a single feature *after* the rebuild, and for the post-clone\ncleanup in Stage 2, use **pikku-build**.\n", "pikku-build/references/app.md": "# Build a product on open-source Pikku\n\nYou have a scaffolded project with skills installed. This skill owns everything\nfrom here: no Fabric account, no card, no hosted build — while keeping the\nproject shaped so `pikku fabric init` later adopts it with zero rework.\n\n**Four phases, and you do not skip ahead:**\n\n1. Write the knowledge graph — what the app IS, before any code\n2. Declare the people and the apps — personas, roles, frontends\n3. Plan the milestones — the buildable pieces, in dependency order\n4. Implement them one at a time — each proven by a scenario before the next starts\n\n## Agent Operating Procedure\n\n1. Discover before editing. Run `pikku info functions --verbose --silent` and\n read `AGENTS.md` before your first change. Read `metaLocale` in\n `pikku.config.json` too: it is the language every `description`, `title` and\n step `template` you write must be in (§1a). Identifiers stay English whatever\n it says.\n2. Make the smallest source change that satisfies the task. Keep generated files\n generated — never hand-edit `.pikku/`, `*.gen.*`, or the SDK.\n3. Validate with the narrowest relevant command, then `pikku all` when functions,\n wirings, schemas or generated clients may have changed.\n4. If validation fails, fix the source cause and rerun. Do not paper over\n generated errors by editing generated files.\n\n## 0. Bootstrap, before anything else\n\n```sh\nbunx --bun pikku bootstrap\n```\n\nOne command, run once, now — not later when you start building. It wires the\n`#pikku` import alias the generated code depends on. On a fresh scaffold `.pikku/`\nis empty, so **every command that touches codegen fails until this has run** —\nincluding ones you would reasonably reach for while still planning, like\n`pikku persona list`. Those failures look alarming (`Cannot find module\n'#pikku/workflow/pikku-workflow-types.gen.js'`, `Schema generation failed for 16\nschemas`) and they are nothing but this missing step.\n\n`pikku knowledge index` and `knowledge validate` work without it — which is why\nthe planning phases below are safe either way.\n\n## 1. One more round of questions — then stop asking\n\nAsk **three to five** questions that actually change the schema or the screens,\nin one message. Then stop; do not interview the user.\n\n- **Who uses it — who are the distinct kinds of people?** Ask this however small\n the app is. The answer becomes §3's personas and roles, and you build only the\n roles they name.\n- **What are the two or three core objects?**\n- **What is the main thing someone does on their first visit?** This answer\n becomes the second milestone, not the tenth.\n- **One app or several?** Separate apps on separate hosts, or one app with paths.\n Cheap to answer now, expensive after the routes exist.\n- **What should it look like?** The template ships one theme — \"Neutral\", a\n deliberately unopinionated monochrome scaffold — and **nothing in the\n open-source toolchain will ever replace it for you.** Accept any of: keep\n Neutral (fine for an internal tool, but say so out loud); a direction in words;\n a reference (brand guide, screenshots, a site whose register they want); or\n their own design agent/prompt, whose output you take as the direction.\n- **What language should the app speak, and what language does the team work\n in?** Two answers, not one — see §1a, which is where they go. Ask only if the\n request is not obviously English; a brief written in English about an English\n product answers both.\n\nSkip anything you can decide yourself. If nobody answers, assume one app with\npaths, the roles implied by the request, Neutral, English — and say so.\n\n## 1a. Three languages, and you must not collapse them\n\nA brief saying \"the entire UI is German\" is about **one** of these. Getting this\nwrong has already shipped a project that can never add a second language, so\nsettle all three explicitly before you write code.\n\n| Axis | What it covers | Where it goes |\n| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |\n| **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Commit messages. | Nowhere — **always English**, no setting, not negotiable |\n| **Meta** | `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role and persona descriptions | `metaLocale` in `pikku.config.json`, default `en` |\n| **Product UI** | Every string the app shows a user | `messages/<locale>.json`, and `defaultLocale` for what a first-time visitor opens in |\n\n**Identifiers are English.** The product's market does not change this and\nneither does `metaLocale`. Identifiers are the surface the generated `#pikku/*`\nclients, `pikku info`, the typed RPC map and the Kysely types all bind to, and\nunlike a string an identifier cannot be translated later — renaming one is a\nmigration. A German practice management tool gets `getWorklist`, `case`,\n`event`, not `getUebersicht`, `vorgang`, `ereignis`.\n\n**Meta follows `metaLocale`.** Write the team's answer into `pikku.config.json` in\nthis phase, before there is any meta to be wrong:\n\n```json\n{ \"metaLocale\": \"de\" }\n```\n\nIt exists for the Pikku Console. Meta is the one part of a project the Console\nrenders back to a human, so a team working in German reads their own functions,\nfeatures and scenario reports in German. Default `en` and do not ask when the\nproject is obviously English. **On every later run, read this field first and\nauthor descriptions, titles and step templates in it** — a project whose\n`metaLocale` you ignored reports half in one language and half in another.\n\n**Product UI is the message catalogue.** `messages/<locale>.json` via\n`pikku-i18n`, with `defaultLocale` deciding what a visitor opens in.\n`baseLocale` in `project.inlang/settings.json` **stays `en`**: it names the\nmessage source, the catalogue every other language is cloned from, so a project\nthat repoints it has nothing to translate from and `--add-locale` is broken\nforever.\n\nA German medical portal, correctly:\n\n```jsonc\n// project.inlang/settings.json\n{ \"baseLocale\": \"en\", \"locales\": [\"en\", \"de\"] }\n// apps/app/src/i18n/active.json (or: fabric i18n --default-locale de)\n{ \"defaultLocale\": \"de\" }\n// pikku.config.json\n{ \"metaLocale\": \"de\" }\n```\n\nRecord the two non-obvious answers as a `decisions/` note in §2 — neither is\ndiscoverable from code, and the next agent will otherwise re-derive them wrong.\n\n---\n\n## PHASE 1 — What the app is\n\n## 2. Write the knowledge graph — before any code\n\n`knowledge/` is not documentation you write at the end. It is the record of what\nthe app IS, in the words its users use, and it is the one part of the project\nanother agent picks up and continues from. **Nothing gets built until there is a\nmilestone note to build.**\n\nRead `knowledge/index.md` and the `pikku-knowledge` skill, then write the notes\nfor what the user just told you. A project whose `knowledge/` is still only the\nshipped index is a project nobody can resume.\n\nFive sections, each answering exactly one question:\n\n- `milestones/` — what is one buildable piece, and what proves it works\n (some scaffolds call these `slices/`; follow the name `knowledge/index.md`\n uses — `knowledge validate` accepts either)\n- `entities/` — what a thing IS, in the words users use for it\n- `decisions/` — what was chosen and what that rules out.\n `decisions/security/` for who may reach what, `decisions/design/` for how it\n looks and behaves\n- `questions/` — what you asked and never got an answer to\n- `wishlist/` — what someone wants that nobody has asked you to build\n\nRules that make it a graph rather than a pile of files:\n\n- **A note's path is its identity.** Markdown, YAML frontmatter, `type` required.\n Cross-link notes with plain markdown links — that is what makes it a graph.\n- **Create a section the turn you have a note for it**, with its own `index.md`\n written in the same turn. Never scaffold empty directories, and never leave\n notes flat at the root: a `product.md` and a `glossary.md` at `knowledge/` is\n not a knowledge base, and it leaves the project unbuildable.\n- **A milestone note carries `status`** (`proposed` → `dispatched` → `built`,\n nothing else), **at most three `entities`** (past three it is not one piece —\n split it), and **its scenario as a fenced ` ```gherkin ` block in the third\n person** — `Given 'owner' has no entry`, never `Given I …`. A quoted word\n MEANS a persona, so quote only personas you declare in §3 and write domain\n values bare. That block becomes a real scenario in §7.\n- **Record only what pikku cannot tell you.** Tables, columns, function\n signatures, routes, wirings, permissions and roles are all discoverable with\n `pikku info` / `pikku meta`. Copying them into a note gives you a second copy\n that goes stale. Knowledge is the why: decisions, constraints, what a thing\n means.\n\nThree decisions belong here on day one, because every later choice leans on them\nand none is discoverable from code:\n\n- **How the product is split into apps**, and why (§4) — `decisions/`\n- **What each kind of person may reach**, in domain language — `decisions/security/`\n- **What the app should look like** — the direction from §1 (§8) — `decisions/design/`\n\nThen keep it honest — both must pass, and `validate` is a real gate:\n\n```sh\nbunx --bun pikku knowledge index\nbunx --bun pikku knowledge validate\n```\n\n---\n\n## PHASE 2 — Who it is for, and what it is made of\n\n## 3. Declare the people — personas and roles\n\nThe answer to \"who uses it\" becomes code, in one file:\n`packages/functions/src/personas.ts`. It ships with a single `visitor`; add the\npeople the user named, and the roles they imply.\n\n```typescript\nimport { definePersonas } from '#pikku/scopes/pikku-personas.gen.js'\nimport { defineSystemRole } from '#pikku/scopes'\n\ndefineSystemRole({\n owner: {\n displayName: 'Owner',\n description: 'Runs their own properties — sees only what they own',\n scopes: [],\n },\n tenant: {\n displayName: 'Tenant',\n description: 'Lives in one unit — sees only their own tenancy',\n scopes: [],\n },\n})\n\ndefinePersonas({\n visitor: { name: 'Visitor', jobTitle: 'Synthetic health-check user', account: {} },\n amina: {\n name: 'Amina',\n jobTitle: 'Property owner',\n personality: 'Checks arrears first, every single time',\n roles: ['owner'],\n account: {},\n },\n bilal: {\n name: 'Bilal',\n jobTitle: 'Property owner',\n personality: 'A second owner — exists so \"you see yours, not theirs\" is testable',\n roles: ['owner'],\n account: {},\n },\n chidi: {\n name: 'Chidi',\n jobTitle: 'Tenant',\n personality: 'Reports the boiler, wants to know it was seen',\n roles: ['tenant'],\n account: {},\n },\n})\n```\n\n- **Keep `visitor`.** The shipped scenarios name `actors.visitor`, and PKU677\n requires a browser step's actor to be a literal `actors.<name>`. Removing it\n stops `actors.visitor` type-checking, which fails `pikku all`, and nothing you\n write after that registers.\n- **One `definePersonas` call for the whole project.** Codegen builds the\n `PersonaId` union from it, materialises one scenario actor per person, and\n seeds a user row each. A second call site is a second answer to \"who uses this\n app\".\n- **`roles` is typechecked against `defineSystemRole`.** An undeclared role is a\n build error rather than a runtime surprise.\n- **Never write an email address.** Each is derived from the persona id and\n `scenarios.emailDomain` in `pikku.config.json` — `visitor@actors.local`.\n Hand-writing one is how a run signs in as somebody who was never created.\n- **Declare a second person of the same kind** whenever the rule is ownership\n (`bilal` above). \"You see yours, not theirs\" is not testable with one owner,\n and §7 is where it gets caught.\n- **Build the roles the user's answer produces, no more.** An invented role\n becomes invented screens and invented rules, and it is the user who has to\n live with them.\n- **Roles are what a permission check reads, not where it lives.** The check goes\n in the function's `permissions` field (§6), never in the body. Read the\n `pikku-auth` skill.\n\n`pikku persona list` shows who is declared and `pikku roles audit` reports roles\nthe database still holds that code no longer declares — both need §0's bootstrap\nto have run, and both are worth a look once it has.\n\n**One warning about the scaffold's own notes:** `knowledge/index.md` may claim\nthe people live in `pikku.config.json`, put there by a persona command.\nThat is stale. In this template they live in `personas.ts` as above, and\n`pikku.config.json` carries no personas key at all — only `scenarios.emailDomain`.\nTrust the file you can read over the note describing it.\n\n## 4. Declare the apps — one API, several frontends\n\n### The rule that makes it cheap\n\n**One backend, many frontends. Never fork `packages/functions`.**\n\nEvery app imports the same generated SDK (`@project/functions-sdk`) and calls the\nsame RPCs. What differs is which screens exist and what the shell looks like.\nWhat must NOT differ is the data layer: two copies of a `listProperties` function\nis two places for the permission check to be wrong.\n\n**The app split is presentation, not security.** A tenant app that simply never\nrenders the arrears screen is not access control — it is a hidden button.\nSecurity is the `permissions` field on the function, and it holds whether the\ncaller arrived from the admin origin, the tenant origin, curl, or the generated\nclient. Build the split for clarity, and prove the boundary with a refusal\nscenario in §7.\n\n### Choosing the split\n\n- **Separate apps on separate hosts** (`admin.example.com`, `portal.example.com`)\n — when the two audiences share almost no screens, when they should not see each\n other's brand register, or when one may later ship independently.\n- **One app with paths** (`/app/admin/*`, `/app/portal/*`) — when they share most\n of the shell and the difference is a handful of screens. Cheaper, and honest:\n two nav trees in one app is still two apps to a user.\n\nEither way, write the choice and its reason into `knowledge/decisions/`.\n\n### Adding a second frontend — later, not now\n\nRecording the decision is Phase 2 work. **Creating the directory is not.**\nCloning `apps/app` materialises a folder of copied screens, so it belongs to the\nmilestone that first needs the second app, not to planning.\n\nWhen you get there, read `references/multi-app.md`. It carries the clone, the\n`package.json` edits, the `pikkufabric.config.json` frontends map, the dev-runner\nchange that otherwise silently never starts your second app, the per-frontend\nscenario environments, and how sessions behave across two origins.\n\nWhat Phase 2 owes you now is only this: the split, its reason, and who each app\nserves, written into `knowledge/decisions/`.\n\n---\n\n## PHASE 3 — The plan\n\n## 5. Plan the milestones\n\nTurn the app into an ordered list of buildable pieces, each a note in\n`knowledge/milestones/`, each `status: proposed` with a gherkin block.\n\nWhat a milestone is:\n\n- **One buildable piece, at most three entities.** Past three it is not one piece.\n- **Vertical, not layered.** \"The owner sees this month's arrears\" is a\n milestone — migration, function, screen, scenario. \"Add the database schema\" is\n not; it is a step inside one.\n- **It ends in something a person can do**, in a browser, signed in as a named\n persona. If you cannot write the gherkin, you cannot build it yet — that is a\n `questions/` note, not a milestone.\n\nHow to order them:\n\n1. **The spine first.** The one object everything else hangs off, and the screen\n that proves the app exists at all.\n2. **Then the loop the user named as \"the main thing someone does on their first\n visit.\"** That answer from §1 is the second milestone, not the tenth.\n3. **Then each audience's own surface**, one at a time. With two apps, finish one\n app's spine before starting the other's — a half-built app in each is worse\n than one working app.\n4. **Refusals ride along with the milestone that creates the thing being\n refused**, never as a \"permissions\" milestone at the end. A milestone that\n creates a row and does not say who may not see it is not finished.\n\nNumber the files (`01-…`, `02-…`) so the order is visible in the tree. Then\n`knowledge index && knowledge validate` before you write a line of code.\n\n**Show the user the list before building.** This is the last cheap moment to\nreorder — after §6 the migrations are numbered and the order is concrete.\n\n## 5a. The technical plan — one milestone at a time, before you build it\n\nThe milestone note says what the app must DO. The **plan** says what has to\nexist for it: the tables, functions, wires, roles, scopes, screens and\nscenarios, split into passes. It is JSON, it lives beside the note, and\n`pikku knowledge plan progress` measures the finished build against it.\n\n**Read `pikku-architect` and follow it.** The plan is the denominator the\ncompletion check divides by, so a builder who writes their own plan can build a\nfraction, plan only that fraction, and certify itself complete. Fabric answers\nthat by giving the plan its own seat; here the defence is the ORDER, and it only\nholds if you keep it: the plan is written against the note in its own turn,\nbefore any of the code it measures exists, and is never edited afterwards to\nmatch what you ended up building. An item that will not land is deferred with\nits reason — `plan defer` — not quietly rewritten. Write it before you open a\nmigration:\n\n```sh\npikku knowledge plan schema # the only spec there is\npikku knowledge plan set <milestone> /tmp/plan.json\npikku knowledge plan show <milestone> --for-build # what you then build\n```\n\n**Plan one milestone at a time, at the moment you are about to build it** — not\nall of them here. A plan written against a note that later moves is worse than\nno plan, and everything after the current milestone is still allowed to move.\n\n---\n\n## PHASE 4 — Build\n\n## 6. Implement milestones, one at a time\n\n**Per milestone** — plan it (§5a), set its note to `status: dispatched`, do the\nsix steps, close it out (§6a), set it to `built`. Do not start the next one\nuntil §6a passes, §7 is green for this one *and §7a shows its functions\ncovered*. A stack of half-milestones cannot be reviewed and cannot be handed\nover, and an uncovered function is a half-milestone whether or not the note says\n`built`.\n\n1. **Migration.** SQL in `db/sqlite/` at the project root, numbered on from the\n ones already there. Apply with `bunx --bun pikku db migrate`, which also\n regenerates the Kysely types your functions import.\n2. **Seed.** Demo rows in `db/sqlite-dev-seed.sql`. **There is no seed command** —\n `bunx --bun pikku db reset` is the only thing that applies the file, and it\n wipes, migrates and seeds in one go (`--no-seed` stops after the migration,\n for working on an empty state the test data would hide). Because reset always\n arrives at a database it just wiped, **the seed file is plain `INSERT`s** — no\n `ON CONFLICT DO NOTHING`, no `INSERT OR IGNORE`. Nothing ever applies it\n twice, so it never has to defend itself. It is also **local only** — no deploy\n applies it, so anything the app would be broken without in production is\n configuration and belongs in a migration, not here.\n Do this generously and do it now: an empty app demos badly and critiques\n badly, and you cannot judge a screen's hierarchy, overflow, or truncation\n against zero rows. Seed rows each persona sees differently — with an ownership\n rule that means seeding rows for the *second* owner too.\n3. **Functions.** One `pikkuFunc` per `*.function.ts`. Mark it `expose: true` and\n Pikku generates the typed RPC client and the React Query hooks the UI calls;\n you do NOT write an HTTP route for it. Add `wireHTTP` only for a real REST\n shape (a third-party webhook).\n4. **Regenerate:** `bunx --bun pikku all`\n5. **UI.** Pages in `<app>/src/pages/`, one route file each in `<app>/src/routes/`,\n calling functions through the generated `usePikkuQuery` / `usePikkuMutation`\n hooks from `@project/functions-sdk/pikku/api.gen`. One component per `.tsx`\n file. Compose the kit from `@/components/<Name>` rather than hand-rolling.\n Register the screen in `useNavItems()` — that one file feeds both the desktop\n sidebar and the phone navigation.\n6. **Scenario** (§7), then `status: built`.\n\nRules that are not optional:\n\n- A function's input and output types come from its `input:`/`output:` zod\n schemas. Never pass generic type params, never annotate the return type inline.\n The schema is the type. (Generics XOR schemas — never both.)\n- Auth and permission checks go in the `permissions` field, never in the function\n body. This is what makes §4's app split safe.\n- No `process.env` inside a function. Read config through the injected\n `variables` / `secrets` services; `process.env` belongs only in bootstrap. Every\n secret a function reads needs a matching `defineSecret`, or `pikku all` reports\n PKU951 and nobody knows what to provision at deploy.\n- Let the database type your values, via `db/annotations.ts`. A `BOOLEAN` column\n is derived for you. On SQLite the other two are **not** — add them by hand,\n once, and they are typed AND coerced end-to-end:\n\n ```typescript\n export const classifications = {\n payment: {\n paid_at: { kind: 'date' }, // -> Date, not an ISO string\n metadata: { kind: 'json', tsType: 'PaymentMeta' }, // -> parsed object, not unknown\n },\n }\n ```\n\n A `TIMESTAMP`/`DATETIME`/`DATE` column with no entry types as `string`, and a\n `JSON` column with no entry types as `unknown` (the CLI warns PKU481). Add the\n annotation rather than casting around the generated type. Once the file carries\n manual fields, `db migrate` stops overwriting it.\n- A `z.date()` on a function's **input** arrives over RPC as an ISO string, not a\n `Date`. Normalise before calling date methods on it (`new Date(value)`), or it\n throws `.getTime is not a function` at runtime — schema validation accepts the\n string without converting it.\n- Every user-facing string is a translation key. Never a hardcoded literal. With\n two apps that means two `messages/` directories; a string used by both belongs\n to whichever app renders it, and duplication beats a shared bundle that couples\n the apps together.\n- Surface errors. No empty catch, no swallowed promise. If a mutation can fail,\n render the failure inline next to the control that triggered it — not a toast.\n- An exposed function with no session and no permission is reachable by anyone\n over `POST /rpc/:rpcName` (PKU574). Either gate it or drop `expose: true`.\n\nThen run it:\n\n```sh\nbun run prebuild && bun run dev\n```\n\nThat starts the API on :3000 and every frontend in `pikkufabric.config.json`. A\nfrontend running against a dead API looks exactly like an app bug, so if every\nrequest fails, check that both halves came up.\n\nThe `--bun` in `bunx --bun pikku …` is load-bearing — keep it. Without it the\nCLI's `#!/usr/bin/env node` shebang hands the process to whatever Node is on\nPATH, which fails below Node 24 with `ERR_UNKNOWN_BUILTIN_MODULE: No such\nbuilt-in module: node:sqlite`.\n\nOpen it, sign up, and click through what you built. **HTTP 200 is not evidence.**\nThe pages are client-rendered: the server returns 200 with an empty shell, so a\npage whose component throws still looks fine to `curl`.\n\nThat click-through is a smoke check, and it is the only thing it is. **Do not\nhand-drive a browser tool in place of a test.** Steering Playwright yourself\nproves a page rendered once, on your machine, in an order only you remember —\nnothing about it re-runs, so the next agent inherits a claim rather than a test,\nand a regression lands silently. When a journey is worth driving through the UI,\nit is worth writing as a browser step on §7's scenario and running\n`pikku scenario run local --spawn --run browser`: same clicks, same assertions,\nin the repo, green or red on every future run.\n\n## 6a. Close the milestone against its plan, not against your memory\n\n```sh\npikku knowledge plan progress <milestone>\n```\n\nIt reads §5a's plan and reconciles it against the generated meta under\n`.pikku/` — the function exists or it does not, the route is wired or it is not,\nthe `pikkuScenario` export is there or it is not. Nothing it reports comes from\nwhat anyone claimed, which is the whole reason it replaced a todo list. It exits\nnon-zero while anything in the first pass is missing.\n\nThree things it says, and what each one asks of you:\n\n- **MISSING** — the first pass owes it and the meta cannot see it. Either build\n it, or, if it genuinely belongs to later work, move it out with a reason on\n the record:\n\n ```sh\n pikku knowledge plan defer <milestone> function:sendReminder \\\n -r \"The email service it needs is the next milestone.\"\n ```\n\n **A deferral is capped at two per plan.** Past that, the plan was wrong and the\n milestone is two milestones — say so to the user rather than deferring again.\n What you may never do is drop the item silently: the plan is what the next\n person reads to know what this milestone was for.\n- **PROBLEMS** — something exists but does not do what was planned. A function\n planned as restricted whose meta says `auth: false`; a `cascade` no migration\n declares; a browser scenario that opens a page and asserts it is still on it.\n These are never deferred. Fix the app.\n- **DEFERRED to a later pass** — already accounted for. Reported so it is\n visible, never blocking.\n\n**Do not set the note to `built` while this exits non-zero**, and do not edit the\nplan to match what you built — `plan set` is the architect's seat, and a builder\nrewriting its own denominator is exactly what the split exists to stop.\n\n## 7. Prove it — scenarios\n\nA scenario is a user journey run as one of your personas, over the real\ntransport, with that persona's session. It is the only kind of test worth writing\nhere, because a passing one proves the app works the way a signed-in person\nexperiences it. Three ship in `packages/functions/test/scenarios/` — keep them\ngreen — and every milestone's gherkin block from §5 becomes one more.\n\n```typescript\nimport { pikkuScenario } from '#pikku/scenarios'\n\nexport const tenantReportsAFaultScenario = pikkuScenario<void, { id: string }>({\n title: 'A tenant reports a fault and the owner sees it',\n description: 'The report lands on the owning landlord’s queue, and nobody else’s',\n tags: ['scenario', 'maintenance'],\n func: async (_services, _data, { scenario, actors }) => {\n const report = await scenario.do(\n 'reports a broken boiler',\n 'createMaintenanceReport',\n { summary: 'No hot water' },\n { actor: actors.chidi },\n )\n await scenario.then(\n 'appears on the owner’s queue',\n 'reportShowsOnQueue',\n { id: report.id },\n { actor: actors.amina },\n )\n await scenario.then(\n 'is invisible to the other owner',\n 'reportIsNotVisible',\n { id: report.id },\n { actor: actors.bilal },\n )\n return { id: report.id }\n },\n})\n```\n\n- **`do` takes an RPC name; `given`/`when`/`then` take a declared step.** A step\n is a `pikkuScenarioStep` that says what a person is doing and holds one\n implementation per surface (server-side by default, plus a `browser` one that\n drives the page). Reaching for an RPC name in a `then` will not resolve.\n- **Every scenario must assert.** A ladder of `given`/`when` with no `then` is a\n PKU680 critical — it fails `pikku all`, so it stops codegen rather than a test.\n Coverage counts every step, so without that rule an assertion-free ladder of\n clicks would score a perfect run while checking nothing.\n- **Write the refusals.** The third step above is the whole point of §4: one\n persona reaching for another's row has to be rejected, and that rejection is a\n scenario. It is how you prove access control instead of asserting it.\n- **Add `SCENARIO_ACTOR_SECRET` to `.env`.** `bun run dev` generates that file\n with a `BETTER_AUTH_SECRET` and nothing else, and without the actor secret\n `/api/auth/sign-in/actor` is disabled — every scenario then fails at sign-in,\n before its first step, for a reason that reads like an auth bug.\n- **There is no state reset.** A scenario runs against a live server: scope what\n you create to your own rows and unique ids, and never assume a clean database.\n\nRun them:\n\n```sh\nbunx --bun pikku scenario run local --spawn # server-side, the fast path\nbunx --bun pikku scenario run local --spawn --run browser # the same journeys, driven as a human\nbunx --bun pikku scenario run local-admin --spawn --run browser # the second app\n```\n\n`--spawn` starts and stops the server for the run; drop it if `bun run dev` is\nalready up. The browser pass needs the environment's `appUrl` and a browser\ndriver installed — without them the run fails fast rather than half-running.\n\n### 7a. Coverage — which functions have actually been run\n\nGreen scenarios tell you the journeys you wrote still work. They say nothing\nabout the code you never wrote a journey for, and that gap is invisible without\nmeasuring it:\n\n```sh\nbunx --bun pikku dev --coverage # server, instrumented\nbunx --bun pikku scenario run local --coverage # against that server\n```\n\nThat writes `coverage/scenario-coverage.json` — which functions each journey\nexercised. **A function no scenario touches has never been run by anything but\nyou, by hand, once.** It compiles, it typechecks, `pikku all` is happy, and\nnobody has proven it does what it says.\n\nRun it **as each milestone closes**, not once at the end. Coverage read per\nmilestone is a short list you can act on — the milestone you just built either\ncovered its own functions or it did not. Read for the first time after ten\nmilestones it is a wall of red that nobody triages, and the honest response to a\nwall of red is to ignore it.\n\nEvery gap is one of three things, and naming which is the point of looking:\n\n- **A missing scenario** — the function matters and no journey reaches it. Write\n the journey. Refusal paths dominate this category, because it is the case you\n are least likely to have clicked through by hand.\n- **A function that should not exist** — nothing reaches it because nothing needs\n it. Delete it. An unused exposed function is also reachable over\n `POST /rpc/:rpcName`, so this is a security finding, not only dead weight.\n- **Genuinely deferred** — real, not yet reachable from the UI. Say so in the\n milestone note that will cover it, so the gap is a decision rather than a\n hole.\n\nReport the number when you hand the milestone over. A number nobody says out\nloud is a number nobody acts on.\n\n## 8. Make it look like someone designed it\n\nTwo separate jobs, and conflating them is why open-source builds come out looking\nlike the template:\n\n- **8a. Direction** — deciding what it should look like. **No open-source tool\n does this.** Fabric has `fabric-theme`; you have §1's answer and this section.\n- **8b. Critique** — judging how well the built screens execute that direction.\n `impeccable` does this well, and it is free.\n\nImpeccable audits the design you chose. It will never tell you the app should\nhave looked like something else — it will happily award a clean bill of health to\na perfectly-executed default. Skip 8a and you ship Neutral with good spacing.\n\n### 8a. Author the theme — the step nothing does for you\n\nThe look lives in `packages/mantine-theme`, and it is data, not code:\n\nThe look lives in `packages/mantine-theme`, and it is data, not code — one JSON\nper theme, `active.json` naming the live one. **Read `references/theming.md`** for\nthe file layout, what each field changes, and how to turn a direction in words\ninto a theme.\n\nTwo things that belong here rather than in the reference, because they govern\nevery screen you then build:\n\n**Set the theme once, don't hardcode colours per component.** A screen full of\ninline `color=\"blue\"` and one-off hex values is why apps look templated. Change\nthe theme, not the components — and keep it theme-aware for light and dark.\n\nWith two apps, **share the theme package and vary the register, not the\npalette.** A back-office can be denser and more tabular; a customer-facing app\ncan be roomier and warmer — that is `structure` and layout, not a second `brand`.\nTwo unrelated colour schemes read as two products from two companies.\n\nThen **write the direction into `knowledge/decisions/design/`** — the words the\nuser gave you, what you chose, and what it rules out. The JSON records what the\ntheme is; only the note records why.\n\n### 8b. Compose real components, then critique\n\n**Compose with Mantine's rich components — not tables and text everywhere:**\n\n- **`@mantine/charts`** (Recharts underneath) for overviews — `AreaChart`,\n `BarChart`, `LineChart`, `DonutChart`, `Sparkline`. A metric worth showing is\n worth a chart, not a number in a `Text`.\n- **`@mantine/dates`** for anything time-based — `DatePicker`, `Calendar`,\n `DateTimePicker`, range inputs. Never hand-roll a date field.\n- Composed layouts over flat lists — `Timeline` for history, `Stepper` for\n multi-step progress, `Card` + `SimpleGrid` for a gallery, `RingProgress` for\n completion, `Badge`/`ThemeIcon` for status.\n\nBoth ship in the template's app dependencies. Look each one up in the Mantine\nllms.txt and use the real component.\n\nThen critique it. Free, and works across coding agents:\n\n```sh\nnpx impeccable install # current releases need Node 22.18+\n```\n\nImpeccable scores a screen against interaction heuristics and names what is\nwrong: hierarchy, spacing, type registers, states you forgot. Run it on **every**\nscreen in **every** app, fix what it finds, and re-run the ones you changed.\n\nScreenshot each page and feed it the images. Without them its findings drop to\ninference from source, and it misses real misalignment, contrast, and overflow.\nJudging your own UI from source code is guessing.\n\n**Screenshot at a phone width too (≈390px), not just desktop, and critique\nthose.** A layout that is fine at 1440px routinely breaks at 390 — a table that\noverflows, a row of buttons that wraps into a pile, text jammed against the edge,\na modal taller than the viewport. Mantine gives you the tools (responsive `Grid`,\n`visibleFrom` / `hiddenFrom`, `Stack` instead of `Group` at small sizes); use\nthem. The template already mounts a phone navigation per `AGENTS.md` — pick\n`MobileTabBar` or `MobileNavDrawer` deliberately per app, never both.\n\nThe gate: **no P0 findings left on any screen, in any app, at either width.**\nDon't silence a finding by deleting the feature it is about.\n\n## 9. Ship it, and stay Fabric-ready\n\nWhen every milestone is `built` and the scenarios are green, read\n`references/ship.md`. It carries the open-source deploy paths (`--provider\nstandalone`, `cloudflare`, `aws`), how to serve several frontends behind one\nAPI, the pre-release gate to run, and the contract that keeps `pikku fabric init`\na one-command import later rather than a migration.\n\nTwo things from it are worth knowing before you get there, because they are\ncheaper to honour than to retrofit:\n\n- **Nothing hardcodes a host, a port, or a `process.env` read inside a\n function.** Secrets go through `defineSecret` and the injected `secrets`\n service. This is the most common reason a working local project fails its\n first deploy, on any platform.\n- **Generated files stay generated.** No hand edits to `.pikku/`, `*.gen.*`, or\n the SDK.\n\n## Reference\n\n- `references/multi-app.md` — adding a second frontend (§4), at the milestone\n that needs it\n- `references/theming.md` — authoring the theme (§8a)\n- `references/ship.md` — deploying, and the Fabric-readiness contract (§9)\n- Sibling skills: `pikku-knowledge` (§2), `pikku-auth` (§3),\n `pikku-scenario` (§7, §7a), `pikku-deploy` and `pikku-fabric` (§9)\n- Project conventions written by the template: `AGENTS.md`\n- Doing less than this: `references/quick.md``. Doing more: `references/platform.md``.\n", "pikku-build/references/feature.md": "# 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.\n6. Report anything about pikku itself that cost you time, the moment it happens — see **Report what fought you**.\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**Language rule:** read `metaLocale` in `pikku.config.json` (default `en`). It is\nthe language of every `description`, `title` and step `template` you author —\nthe meta the Pikku Console renders back to the team. It renames nothing:\nfunctions, components, types, files, tables and columns are English in every\nproject, and what the app says to its users is the message catalogue. See\n`pikku-concepts` → _What Language You Write In_.\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/error` — `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-services** 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**: `#pikku` is a namespace, not a module — one subpath per wiring.\n `pikkuFunc` / `pikkuSessionlessFunc` come from `'#pikku/function'`, `wireHTTP`\n from `'#pikku/http'`. 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## Report what fought you\n\nWhen pikku itself is what cost you time, report it with `pikku fabric report`.\nNothing is written to the repo; the finding goes to the linked fabric project\nand the terminal shows you exactly what was sent.\n\n**Report at the moment it happens**, not at the end from memory — a run that\nfalls over never reaches its end. One finding per thing that fought you.\n\n### The ladder\n\n1. **Find the quicker workaround.** The user is paying for their feature, not\n for pikku's health.\n2. **Investigate** only when there is no workaround, or when the user asks why\n something is slow or wrong.\n3. **Report at the depth you already reached.** Never spend extra effort to\n file; never throw away effort you already spent. If the investigation took\n you to the mechanism, the finding says so — named file, named function, what\n is actually happening, and what pikku should do instead.\n\n**Never fix pikku itself.** Not a patch in `node_modules`, not a linked\ncheckout, not a branch in the framework repo. Many agents each patching pikku to\nunblock themselves is many divergent copies and a merge problem nobody signed up\nfor. Work around it in the app, report it, and let the fix happen once.\n\n### What counts\n\nAnything that cost you time and would cost the next person the same. Most of\nthese never produce an error: output that is quietly wrong, a generated type\nthat disagrees with the runtime, a check that passes when it should not, a\nnarrowing you had to write by hand because the framework should have written it.\n**Having to write code the framework should have written for you is a finding.**\n\nSo is anything that only shows up in one place — invisible locally, fatal\ndeployed, or the reverse. Say which, with `--surface`.\n\nNot a finding: a preference, a thing you would have designed differently, or\nbaseline noise that was already failing before you started.\n\n### Two kinds\n\n- `--kind product` — pikku behaved wrongly. Fixing it is a change to the\n framework.\n- `--kind harness` — a skill misled you: it told you to run something that does\n not exist, described a flag that is spelled differently, or contradicted what\n the CLI actually did. Pass `--skill <name>` and `--passage \"<the line or\n section>\"`. This is the most useful kind to file, because it is fixable\n immediately — so file it even when the cost was small.\n\n### When there was no workaround\n\nReport it anyway with `--unresolved`, and put what you tried and how each\nattempt failed in `--tried`. That is what stops the next person walking the same\ndead ends. Tell the user what you did instead — abandoned it, shipped something\ndegraded, or stopped.\n\n`--unresolved` means **no workaround was found**. It does not mean the\nworkaround was unpleasant.\n\n### The command\n\nSend it as JSON on stdin. Most of a finding is prose, and prose carries\napostrophes, quotes, backticks and newlines — each one a shell metacharacter\nbefore it is a character in your sentence. A stack trace passed to `--error`\nbreaks the command at its first newline; a backtick in `--actual` runs whatever\nfollows it. Quote the heredoc delimiter (`<<'EOF'`, never `<<EOF`) so the shell\nleaves the body alone.\n\n```bash\npikku fabric report --stdin <<'EOF'\n{\n \"title\": \"<one-line title>\",\n \"kind\": \"product\",\n \"model\": \"<the model you are>\",\n \"expected\": \"<what you expected pikku to do>\",\n \"actual\": \"<what it did instead>\",\n \"command\": \"<the command you ran>\",\n \"workaround\": \"<what you did instead, inside the app>\"\n}\nEOF\n```\n\nAdd whichever of these you actually have: `error` (the error's message line,\nverbatim), `repro` (the shortest way to reach it again), `proposal` (what pikku\nshould do), `area`, `surface` (`local`, `deployed` or `both`), `cost` (measured\nif you measured it — \"98s vs 20s steady\" ranks; \"slow\" does not), `run` (an id\nshared by every finding from this build), `deployTarget`.\n\nThe same fields exist as flags — `--kind`, `--expected` and so on — for a\nfinding short enough to type. Anything with a newline or a quote in it goes\nthrough `--stdin`.\n\nVersions, platform and package manager are read off the installed tree for you.\nDo not pass them and do not ask the user for them.\n\nReporting never fails a build. A finding that cannot be sent — logged out, or\nfabric unreachable — is held on the machine and goes out with the next report\nthat succeeds, so nothing you file is lost. If it says the finding was queued,\ncarry on with the feature; do not try to fix it, and do not file it again.\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, except\n `pikku fabric report`, which is explicitly permitted\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-build/references/multi-app.md": "# Adding a second frontend\n\nRead this when the split you recorded in Phase 2 is \"separate apps\" and you have\nreached the milestone that needs the second one. **Not before.** Cloning\n`apps/app` materialises a directory of copied screens; doing it during planning\nleaves `pikkufabric.config.json` pointing at an app nobody has designed yet.\n\nIf the split is \"one app with paths\", you never need this file — add route\nsegments under `/app` and give each audience its own entries in `useNavItems()`.\n\n## Deciding it is two apps, not one\n\nThe split you are acting on should already be recorded, but this is the reasoning\nbehind it — and the place people get it wrong is the third case at the bottom.\n\n**A group that comes in through its own front door gets its own app.** A role\n*inside* an app is not that: it changes which nav items and which buttons a person\nsees, and lives in `useNavItems()` and the `permissions` on the function, not in a\nroute subtree.\n\n**The test is which side of the counter they are on.** Colleagues share one app and\ndiffer by nav — the mechanic, the person on the counter, the bookkeeper. Someone\nacross the counter with an account of their own gets their own — the customer, the\ntenant, the patient. One app is a real answer and often the right one.\n\n**The asymmetry that forces a split is sign-up.** Where staff accounts are created\n*for* people and customers create their own, the two need different sign-up, different\nonboarding and different session shape, and bending one app around both costs more\nthan the second app does. Do not collapse two audiences into one app to save a build.\n\nNever invent a person the notes do not name in order to reach two.\n\n### The group that never signs in\n\nSome people use the product with no account at all — ordering from a menu, booking a\ntable, opening an invitation. They are not a third case, and **they do not get their\nown frontend**: an app is built around the personas who sign into it. What they get is\nthe public route space every app already has.\n\n- **`/app/*` is the signed-in application.** One `beforeLoad` on `/app` bounces a\n signed-out visitor to the login. There is no per-route exception.\n- **Every other route is public** — `/`, `/menu`, `/book`, `/r/$code`. No gate, no\n session.\n- **`/` is a landing page and you must write it.** A starter that forwards `/` to\n `/app` does so only because it ships no homepage. Leave the forward in and the\n product's front door is a sign-in form: the anonymous visitor arrives at a login it\n has no account for and never reaches the thing it came for — **while every check\n still passes**, because everything that looks at the app signs in first. This is the\n failure this section exists for.\n\nSo a screen whose users have no account goes at `/menu`, never `/app/menu`.\n\n### The frontend guard is UX and proves nothing\n\nThe bundle is on the origin and the nav is a client-side decision; anyone can read\nboth. The security boundary is the `permissions` field on the function — see\npikku-permissions. Hiding a nav item keeps people out of screens that would confuse\nthem; it never protects data. Never let a hidden UI be the only thing between a user\nand someone else's record: if the invoices nav item is hidden but `listAllInvoices`\nhas no `permissions`, the app is wide open and the nav is decoration.\n\nWorth a scenario each, because they are two different claims: that a mechanic cannot\n*see* the invoices nav item, and that their call to an invoices RPC is *refused*. The\nsecond is the one that catches a `permissions` field nobody wired.\n\n## The clone\n\n```bash\ncp -R apps/app apps/admin\nrm -rf apps/admin/node_modules apps/admin/src/paraglide\n```\n\n`src/paraglide` is compiled from `messages/` by the Vite plugin on first run;\ncopying it forward ships one app's compiled strings inside another.\n\nThen, in order:\n\n### 1. `apps/admin/package.json`\n\n- `name` → `@project/admin`\n- `dev` and `preview` ports → `7105`. Every frontend needs its own, or the second\n one fails to bind and the dev runner looks like it hung.\n- the `--tsBuildInfoFile` path inside the **`tsc` script** → `admin-tsc.tsbuildinfo`.\n In this template it is a CLI flag on that script (`tsc --noEmit --incremental\n --tsBuildInfoFile node_modules/.cache/app-tsc.tsbuildinfo`), not a\n `compilerOptions` entry — `tsconfig.json` needs no change. Left alone, the two\n apps fight over one incremental cache and you get type errors that vanish on a\n clean build: an hour of debugging for a one-word edit.\n\n### 2. `pikkufabric.config.json`\n\nThis file is the source of truth for what apps exist, and it is read whether or\nnot you ever deploy to Fabric.\n\n```json\n{\n \"projectId\": \"__PROJECT_ID__\",\n \"frontends\": {\n \"app\": {\n \"cwd\": \"apps/app\", \"primary\": true, \"deploy\": true, \"kind\": \"ssr\",\n \"dev\": { \"command\": [\"bun\", \"run\", \"dev\"], \"port\": 7104, \"healthPath\": \"/\" },\n \"serves\": \"tenant\",\n \"personas\": [\"visitor\", \"chidi\"]\n },\n \"admin\": {\n \"cwd\": \"apps/admin\", \"primary\": false, \"deploy\": true, \"kind\": \"ssr\",\n \"dev\": { \"command\": [\"bun\", \"run\", \"dev\"], \"port\": 7105, \"healthPath\": \"/\" },\n \"serves\": \"owner\",\n \"personas\": [\"amina\", \"bilal\"]\n }\n }\n}\n```\n\nTwo things to fix while you are in here, not just add:\n\n- **The shipped `app` entry may say `[\"yarn\", \"dev\"]`** while the rest of the\n project is driven with bun. Correct it. A frontend that starts under a package\n manager the project does not use is a failure that only appears on someone\n else's machine.\n- **`serves` and `personas` name real personas.** Every persona should appear\n under exactly one frontend. A persona listed nowhere is a person with no way\n in, and that is a design bug worth seeing now rather than at review.\n\n### 3. The dev runner\n\n`dev.mjs`, under the project's scripts directory, spawns `@project/app` **by\nname** and will silently never start your second app — the frontend simply is not\nthere, with no error to explain it.\n\nMake it read the `frontends` map and spawn one child per entry, rather than\nadding a second hardcoded line. Two sources of truth for \"which apps exist\" is\nthe drift this whole file is trying to avoid.\n\n### 4. `pikku.config.json` → `environments`\n\n`local.appUrl` points at one app. Add an environment per frontend (`local`,\n`local-admin`) so the browser scenario pass can drive either one. A browser\nscenario run against the wrong `appUrl` fails on a missing element and reads like\na UI bug rather than a config one.\n\n### 5. Re-run `bun install`\n\n`apps/*` is already globbed in the root workspaces, so this just links the new\none.\n\n## Sessions across two origins\n\nBetter Auth lives once, at `/api/auth/*`, and every app proxies to it (see\n`vite.config.ts` — `/api/auth` keeps its prefix, everything else under `/api` is\nrewritten to the pikku dev server).\n\n- **In local dev, cookies are scoped by host and ignore the port**, so\n `localhost:7104` and `localhost:7105` share a session. Convenient, and a trap:\n the app boundary is invisible in dev and only the role check is doing work.\n That is the correct design — but do not read a working dev session as evidence\n the permission check exists. The refusal scenario is the evidence.\n- **In production on two subdomains**, the session cookie needs a parent domain\n (`.example.com`) or each app gets its own login. Decide which, set it per the\n `pikku-auth` skill, and record it in `knowledge/decisions/security/`.\n- **Never hardcode a host or port.** The API base resolves to same-origin `/api`.\n\n## Building the second app's screens\n\nSame rules as the first: pages in `apps/admin/src/pages/`, routes in\n`apps/admin/src/routes/`, the same generated hooks from\n`@project/functions-sdk/pikku/api.gen`, its own `useNavItems()`, its own\n`messages/` directory.\n\nA string used by both apps belongs to whichever app renders it. Duplicating it\nbeats a shared bundle that couples the two apps together — the moment they share\na string file, they share a release.\n", "pikku-build/references/platform.md": "# Build a platform showcase on Pikku\n\n**This skill is a delta. `references/app.md` is the base — read it and follow it in\nfull.** Everything there applies: knowledge base first, personas and roles,\nmilestones planned then built one at a time, scenarios, design pass, deploy\ngates, Fabric-readiness. This file adds the surfaces that turn an app into a\ndemonstration of the platform, and says where each one slots into that workflow.\n\nRead `references/app.md` now, then come back. Do not blend the two into one plan —\nthe phases below hang off its phases by number.\n\n## What \"platform\" means here\n\nBreadth is the deliverable. A showcase that does one thing beautifully has\nfailed; a showcase where every surface is a stub has also failed. The bar for\neach surface below: **it does something the app genuinely needs, and a scenario\nproves it.** A cron job that logs \"tick\" is not a schedule — it is a comment.\n\nBudget the extra surfaces at one milestone each. They are not free, and a\nhalf-wired workflow engine is worse than no workflow engine.\n\n## Choosing surfaces — during `references/app.md` §5 (planning)\n\nWhen you plan milestones, each surface below becomes its own milestone note in\n`knowledge/milestones/`, ordered after the spine it depends on.\n\n**Five are required, and if the domain does not motivate them you chose the\nwrong domain:** workflows, schedules, queues, an AI agent, and realtime. They are\nwhat \"platform\" means here, and a showcase missing one of them is a showcase of\nsomething else. Pick a domain that needs all five — that choice is yours to make\nat §1, and it is much cheaper than contriving a use later.\n\nThe rest — MCP, triggers and webhooks, extra locales, contract versioning,\naddons — are chosen on merit. Write the motivation into the note. If you cannot\nname what one of *those* is for in this app, drop it and say why in\n`knowledge/decisions/`: a documented omission is a stronger showcase than a\ncontrived inclusion.\n\nWhat is never acceptable is a required surface present as a stub. A cron job\nthat logs `tick` does not become a schedule by existing, and it is worse than\nthe documented omission because it claims to be finished.\n\nMost surfaces are switched on by the CLI rather than hand-wired:\n\n```sh\nbunx --bun pikku enable workflow # workflow workers\nbunx --bun pikku enable agent # public agent endpoints\nbunx --bun pikku enable events # realtime events channel + SSE stream\nbunx --bun pikku enable remote-rpc # internal RPC queue worker + HTTP endpoint\nbunx --bun pikku enable webhook # outgoing webhook delivery queue worker\nbunx --bun pikku enable scenarios # scenario instrumentation\nbunx --bun pikku enable console # console functions\nbunx --bun pikku enable rpc # public RPC endpoint\n```\n\nEach scaffolds a `*.gen.ts` and wires it. Run the enable, then `pikku all`, then\nwrite the function — not the other way round.\n\n## The surfaces\n\nEach has an installed skill that is authoritative on its API. Read it before\nwriting the wiring; this section says what the surface is *for* and how to prove\nit, not how to call it.\n\n### Workflows — `pikku-workflow`\n\nThe one that most changes how an app is built. A workflow is a durable,\nresumable, multi-step process — approval chains, onboarding, anything that waits\non a human or a timer and must survive a restart.\n\n- **Motivation test:** is there a process here with more than one step and a\n gap in the middle? If every operation completes in one request, you do not need\n workflows and forcing one is noise.\n- **Prove it:** a scenario that starts the workflow, advances it as a second\n persona, and asserts the end state. `pikku-react` covers driving it\n from the UI.\n- Three workflows ship with the template. Read them before writing yours.\n\n### Schedules — `pikku-wiring`\n\nRecurring work: a nightly rollup, a reminder sweep, an expiry pass.\n\n- **Motivation test:** something in the domain becomes true with the passage of\n time rather than a user action. Rent falls due. A trial ends. A report is\n monthly.\n- **Prove it:** invoke the scheduled function directly in a scenario and assert\n its effect. Do not test by waiting.\n\n### Queues — `pikku-wiring`\n\nWork that must happen but not now, and may retry: email fan-out, image\nprocessing, third-party calls that fail.\n\n- **Motivation test:** an operation the user should not wait for, or one that\n fails in ways worth retrying.\n- **Prove it:** enqueue in one scenario step, assert the effect in a `then`.\n\n### An AI agent — `pikku-agent`\n\nThe template ships agent wiring and `@ai-sdk/openai`. An agent that answers\nquestions over the app's own data is the showcase; a general chatbot is not.\n\n- **Give it real tools** — your own exposed RPCs, so it answers from the\n database rather than from the model. `pikku all --strict-meta` fails a tool\n with no description, which is the quality gate here: an undescribed tool is\n offered to the model under its bare name and it will misuse it.\n- **Gate it.** `getAgentThreads` ships exposed and sessionless — PKU574 flags it.\n Deciding who may reach the agent is part of building it.\n- **Prove it** with a scenario that asks something only the database knows.\n- Needs a model key. Read it through the injected `secrets` service with a\n matching `defineSecret`, never `process.env`, or deploy has nothing to\n provision (PKU951).\n\n### Realtime and events — `pikku-wiring`\n\n`pikku enable events` gives a realtime channel plus an SSE stream, and the\ngenerated typed client.\n\n- **Motivation test:** two people looking at the same thing at the same time, or\n a long operation whose progress matters.\n- **Prove it:** a browser scenario is the only honest proof — assert the second\n persona's screen changed without a reload.\n\n### MCP — `pikku-wiring`\n\nExposes functions as Model Context Protocol tools, so an outside agent can drive\nthe app. Cheap once functions exist, and a genuine differentiator to show.\n\n### Triggers and webhooks — `pikku-wiring`, `pikku enable webhook`\n\nInbound triggers and outgoing webhook delivery. This is where `wireHTTP` is\ncorrect rather than a smell: a third-party caller needs a real REST shape.\n\n### Locales — `pikku-i18n`\n\n`references/app.md` already requires every string to be a key. **Here, ship three\nlocales, and make one of them RTL** (`pikku-i18n`). Two LTR locales prove the\nplumbing; an RTL one proves the layout, and it will find real bugs — mirrored\nicons, hardcoded `marginLeft`, a nav that opens on the wrong side.\n\nAdding a language means adding a locale file. If it means touching components,\nthat is the finding.\n\n`baseLocale` stays `en` through all of this — it names the message source that\nevery added locale is cloned from, so three locales is `locales: [\"en\", …]` and\nnever a repointed base. Shipping locales is also not a reason for anything in\nthe code to stop being English: identifiers are English in every project, and\nthe language of `description`/`title`/`template` is `metaLocale` in\n`pikku.config.json`. See `references/app.md` §1a.\n\n### Emails — `pikku-emails`\n\nTemplates in `emails/`, rendered and sent through the injected `email` service,\nlocalised like every other string. The base workflow asks for one; **a showcase\nsends three** — a welcome, a transactional confirmation, and one sent from a\nschedule or queue rather than a request, because that is the interesting path.\n\n### Contract versioning — `pikku-meta`\n\n```sh\nbunx --bun pikku versions init\n```\n\nThe CLI suggests this on every run of a project without it. Versioning a function\ncontract, then changing it, is a short milestone that shows something no\nscaffold demonstrates on its own. `pikku semver` derives the release version by\ncomparing this build's surface against a deployed one.\n\n### Addons — `pikku-addon`\n\n`pikku new addon` scaffolds a publishable addon package. Worth one milestone if\nthe domain has a piece that genuinely belongs to no single app.\n\n## Coverage — where the bar is higher than the base workflow\n\n`references/app.md` §7a already has the mechanics and the per-milestone habit:\nrun the server instrumented, run the scenarios against it, read\n`coverage/scenario-coverage.json`, and triage every gap as a missing scenario, a\nfunction that should not exist, or a documented deferral. Do all of that here.\n\nTwo things change in a showcase:\n\n- **Every surface you enabled has to appear in the coverage, not just every\n function.** A workflow, a schedule, a queue worker and an agent each run on\n their own path; a green scenario suite that never advances the workflow past\n step one is the difference between \"the platform does workflows\" and \"there is\n a workflow file in this repo\". Check the surfaces by name, because a coverage\n percentage in the nineties hides an entire unexercised surface comfortably.\n- **The number is part of the deliverable.** A showcase is read as evidence, so\n publish the figure alongside it. An unreported number invites the reader to\n assume the worst, and in a demo repo they are usually right to.\n\n## The full gate\n\nEverything in `references/app.md` §9, plus the checks a showcase should be able to\nsurvive:\n\n```sh\nbunx --bun pikku all --tsc-summary --fail-on-warn --strict-meta\nbunx --bun pikku all --security --fail-on-error\nbunx --bun pikku validate\nbunx --bun pikku knowledge validate\nbunx --bun pikku audit\nbunx --bun pikku scenario run local --spawn --coverage\nbunx --bun pikku scenario run local --spawn --run browser\n```\n\n- `--strict-meta` fails an agent tool with no description.\n- `--security` runs the data-classification lint over function return types,\n catching a `Pii`/`Secret` field leaking through an exposed function. Expensive;\n worth it here.\n- `pikku audit` reports dependency advisories (`--outdated` adds available\n updates) into `.pikku/audit.json`.\n\n## Deploy\n\n`references/app.md` §9 covers the open-source paths (`--provider standalone`,\n`cloudflare`, `aws`). One thing specific to this mode: **the extra surfaces are\nextra deploy units.** Workflow workers, queue workers, schedules and the events\nchannel each appear in `pikku deploy plan` as their own entries. Read the plan\nbefore applying — that list is also the clearest inventory of what you actually\nbuilt.\n\n## Reference\n\n- Base workflow: `references/app.md` — read it first, follow it in full\n- Per-surface skills: `pikku-workflow`, `pikku-wiring`, `pikku-agent`,\n `pikku-i18n`, `pikku-emails`,\n `pikku-meta`, `pikku-addon`, `pikku-auth`, `pikku-services`\n- Every feature, end to end: https://pikkufabric.com/llm-all-features.txt\n", "pikku-build/references/post-clone.md": "# 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.\n\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-build/references/quick.md": "# Build an app on Pikku, fast\n\nYou have a scaffolded project with skills installed. Get it to working, seeded,\nsigned-in screens in as few steps as possible.\n\n**What this mode deliberately skips**, and what that costs:\n\n| Skipped | Cost |\n|---|---|\n| `knowledge/` | Another agent — or you next week — cannot resume this. Nothing records *why*. |\n| Milestone planning | No build order, no per-piece proof. Fine at this size, painful past it. |\n| Design direction | It will look like the template. |\n| Refusal scenarios | Access control is asserted, not proven. |\n\n**Say this out loud to the user, once, when you finish.** A quick build that gets\nmistaken for a real one is the only way this mode does damage. §6 is the way out.\n\n## Agent Operating Procedure\n\n1. Read `AGENTS.md` at the project root before your first screen — routing slots,\n `useNavItems()`, and the shipped component kit.\n2. Keep generated files generated. Never hand-edit `.pikku/`, `*.gen.*`, or the SDK.\n3. Run `pikku all` after touching functions, wirings or schemas. It is the gate,\n and its criticals are real.\n\n## 1. One question, then build\n\nAsk **one** thing, and only if the original request left it open: **who uses\nit — one kind of person, or several?** Everything else you decide yourself.\n\n- **One kind** — no roles to declare. The rule is ownership: you see yours, not\n theirs.\n- **Several** — declare a role each in §2 and keep the count honest. An invented\n role becomes invented screens.\n\nDo not ask about design, deployment, or scope. This is the quick mode; the\ndefaults are the point.\n\nDo not ask about language either — take the defaults and note them in §6.\nIdentifiers are English in every project, whatever the product's market;\n`metaLocale` in `pikku.config.json` (the language of `description`/`title`/step\n`template`, which the Console renders) stays `en` unless the user already told\nyou otherwise. If the request says the app's UI is not English, that is the\nmessage catalogue only: add the locale and set `defaultLocale`, and leave\n`baseLocale` at `en`. `references/app.md` §1a has the three axes in full; getting\nthem confused is how a project ends up unable to add a second language.\n\n## 2. Personas — 60 seconds, not optional\n\n`packages/functions/src/personas.ts` ships with a `visitor`. Add one persona per\nkind of person, plus **a second one of the primary kind** — that is what makes\n\"you see yours, not theirs\" observable when you click around.\n\n**One kind of person** — no `defineSystemRole` at all. Ownership is the only\nrule, and it lives in each function's `permissions`, not in a role:\n\n```typescript\nimport { definePersonas } from '#pikku/scopes/pikku-personas.gen.js'\n\ndefinePersonas({\n visitor: { name: 'Visitor', jobTitle: 'Synthetic health-check user', account: {} },\n amina: { name: 'Amina', jobTitle: 'Gardener', account: {} },\n bilal: { name: 'Bilal', jobTitle: 'Gardener', account: {} },\n})\n```\n\n**Several kinds** — one role each, and only for the kinds the user actually\nnamed:\n\n```typescript\nimport { definePersonas } from '#pikku/scopes/pikku-personas.gen.js'\nimport { defineSystemRole } from '#pikku/scopes'\n\ndefineSystemRole({\n owner: { displayName: 'Owner', description: 'Sees only their own rows', scopes: [] },\n tenant: { displayName: 'Tenant', description: 'Sees only their own tenancy', scopes: [] },\n})\n\ndefinePersonas({\n visitor: { name: 'Visitor', jobTitle: 'Synthetic health-check user', account: {} },\n amina: { name: 'Amina', jobTitle: 'Owner', roles: ['owner'], account: {} },\n bilal: { name: 'Bilal', jobTitle: 'Owner', roles: ['owner'], account: {} },\n chidi: { name: 'Chidi', jobTitle: 'Tenant', roles: ['tenant'], account: {} },\n})\n```\n\nTwo owners in both examples, deliberately: one owner cannot demonstrate that\nowners are separated from each other.\n\n- **Keep `visitor`.** The shipped scenarios name `actors.visitor`; removing it\n fails `pikku all` and nothing you write after that registers.\n- **One `definePersonas` call for the whole project.**\n- **Never write an email address** — each is derived from the persona id and\n `scenarios.emailDomain` in `pikku.config.json`.\n- `roles` is typechecked against `defineSystemRole`; an undeclared role is a\n build error.\n\n## 3. Build\n\nRun `bunx --bun pikku bootstrap` once first. It wires the `#pikku` import alias\ncodegen depends on; without it your first `db migrate` fails with\n`Cannot find package '#pikku'`.\n\nThen, in this order — it is the order codegen depends on:\n\n1. **Migration** — SQL in `db/sqlite/`, numbered on from what is there. Apply\n with `bunx --bun pikku db migrate`, which regenerates the Kysely types.\n2. **Seed** — rows in `db/sqlite-dev-seed.sql`. There is no seed command:\n `bunx --bun pikku db reset` wipes, migrates and seeds in one go, and is the\n only thing that applies the file. It always starts from a wiped database, so\n the file is plain `INSERT`s — no `ON CONFLICT DO NOTHING`. It is local only:\n no deploy applies it, so anything the app cannot run without belongs in a\n migration instead. **Be generous, and\n seed rows for both personas.** An empty app demos badly, and you cannot see a\n layout break against zero rows.\n3. **Functions** — one `pikkuFunc` per `*.function.ts`, `expose: true`. Pikku\n generates the typed RPC client and React Query hooks; you do NOT write HTTP\n routes. `wireHTTP` only for a real REST shape (a third-party webhook).\n4. `bunx --bun pikku all`\n5. **UI** — pages in `apps/app/src/pages/`, one route file each in\n `apps/app/src/routes/`, calling `usePikkuQuery` / `usePikkuMutation` from\n `@project/functions-sdk/pikku/api.gen`. Compose `@/components/<Name>` —\n `PageHeader`, `Panel`, `StatGrid`, `DataTable` — rather than hand-rolling.\n Register each screen in `useNavItems()`; that one file feeds the desktop\n sidebar and the phone navigation.\n\n**Aim for two or three real entities and three screens** — a working surface, a\ndetail view, and somewhere to land. One table with a form on it is not an app,\nand it is not faster to build.\n\nRules that stay non-negotiable even here, because breaking them costs more time\nthan they save:\n\n- Input and output types come from `input:`/`output:` zod schemas. Never generic\n type params, never an inline return type. The schema is the type.\n- Permission checks go in the `permissions` field, never the function body. An\n exposed function with no session and no permission is reachable by anyone over\n `POST /rpc/:rpcName` (PKU574).\n- No `process.env` inside a function — use the injected `variables` / `secrets`\n services.\n- A `z.date()` **input** arrives over RPC as an ISO string, not a `Date`.\n `new Date(value)` before calling date methods, or it throws\n `.getTime is not a function` at runtime.\n- On SQLite, `db/annotations.ts` is where a `DATETIME` becomes a `Date` and a\n `JSON` column becomes a typed object. Without an entry they are `string` and\n `unknown` (PKU481). Add the annotation rather than casting.\n- Surface errors inline next to the control that failed. No empty catch.\n- Every user-facing string is a translation key, not a literal. It is one extra\n keystroke now and a rewrite later.\n- Never hardcode a host or port — the API base resolves to same-origin `/api`.\n\nThen run it:\n\n```sh\nbun run prebuild && bun run dev\n```\n\nAPI on :3000, app on the port vite prints. A frontend against a dead API looks\nexactly like an app bug, so if every request fails, check both came up.\n\n## 4. Look at it — actually\n\nSign up, click every screen. **HTTP 200 is not evidence:** pages are\nclient-rendered, so the server returns 200 with an empty shell and a page whose\ncomponent throws still looks fine to `curl`.\n\nLooking is for the layout — the part only eyes catch. Assertions belong in the\nsmoke scenario, not in a browser session you steered by hand: that session proves\na screen rendered once, here, and nothing about it re-runs.\n\n**Screenshot at 390px too.** A layout that is fine at 1440 routinely breaks on a\nphone — an overflowing table, a row of buttons wrapped into a pile, a modal\ntaller than the viewport. It is the most likely width your demo gets opened at.\n\nIf you have five spare minutes, `npx impeccable install` (Node 22.18+) scores\neach screen against interaction heuristics and names what is wrong. Feed it\nscreenshots, not source. It will polish the default look; it will not give the\napp a look — that is `references/app.md` §8a.\n\n## 5. One smoke scenario\n\nNot the full ladder — one journey, end to end, as a real persona, so the app has\nat least one thing that stays true.\n\n```typescript\nimport { pikkuScenario } from '#pikku/scenarios'\n\nexport const ownerCreatesAndSeesItScenario = pikkuScenario<void, { id: string }>({\n title: 'An owner creates a thing and sees it',\n tags: ['scenario', 'smoke'],\n func: async (_services, _data, { scenario, actors }) => {\n const row = await scenario.do('creates', 'createThing', { name: 'first' }, { actor: actors.amina })\n await scenario.then('sees it listed', 'thingShowsInList', { id: row.id }, { actor: actors.amina })\n return { id: row.id }\n },\n})\n```\n\n- **`do` takes an RPC name; `given`/`when`/`then` take a declared\n `pikkuScenarioStep`.** An RPC name in a `then` will not resolve.\n- **Every scenario must assert.** A ladder with no `then` is a PKU680 critical —\n it fails `pikku all`, stopping codegen rather than a test.\n- **Add `SCENARIO_ACTOR_SECRET` to `.env`.** `bun run dev` writes that file with\n only a `BETTER_AUTH_SECRET`; without the actor secret\n `/api/auth/sign-in/actor` is disabled and every scenario fails at sign-in, for\n a reason that reads like an auth bug.\n- **There is no state reset** — scope what you create to unique ids.\n\nKeep the three shipped scenarios in `packages/functions/test/scenarios/` green.\n\n```sh\nbunx --bun pikku scenario run local --spawn\n```\n\n## 6. Hand it over honestly\n\nTell the user, in one short paragraph: what runs, what it is seeded with, and\nthat this is a quick build — no knowledge base, no milestones, no design pass,\naccess control clicked-through rather than proven.\n\n**Upgrading to a real build is additive, not a rewrite.** If they want it, switch\nto `references/app.md` and do this, in order:\n\n1. Write `knowledge/` for what already exists — `entities/` for what you built,\n `decisions/` for what you chose silently, `questions/` for what you guessed\n at. Then `pikku knowledge index && pikku knowledge validate`.\n2. Backfill a milestone note per screen you built, at `status: built`, each with\n its gherkin block.\n3. Write the refusal scenarios — the ones proving one persona cannot reach\n another's rows. This is the gap that matters most.\n4. Then pick up `references/app.md` at its §4 (apps) or §5 (milestones) for\n anything new.\n\nNothing built here has to be thrown away to do that — which is the whole reason\nthis mode is allowed to skip those steps in the first place.\n", "pikku-build/references/ship.md": "# Shipping, and staying Fabric-ready\n\nRead this when the milestones are built and the scenarios are green — it is\nthe last phase, and nothing in it is needed before then.\n\n## Ship it — open source, no platform\n\n`pikku deploy` builds and ships without any hosted service:\n\n```sh\nbunx --bun pikku deploy plan --provider standalone --runtime bun\nbunx --bun pikku deploy apply --provider standalone --runtime bun\n```\n\n`standalone` comes from the installed `@pikku/deploy-standalone` adapter: it\nbundles the project into a single unit and emits either a `bundle.js` you run\nwith Node, or a self-contained executable compiled with `bun build --compile`.\n`cloudflare` (the default) and `aws` are the other providers — read the\n`pikku-deploy` skill before using it, as it ships the handler\nfactories the deploy codegen expects, and hand-rolling an `ExportedHandler` is\nhow a worker deploy fails at runtime instead of at build.\n\nAlways run `plan` before `apply`, and read it. It names what will be created,\nupdated and deleted — the deletions are the reason to look.\n\nThe frontends build independently (`bun run build` at the root builds every\nworkspace). Serve each behind its own hostname, and put the API behind `/api` on\n**all of them**, mirroring the Vite proxy from the multi-app reference: `/api/auth/*` keeps its\nprefix, everything else under `/api/*` reaches the pikku server unprefixed. Get\nthis wrong and sign-in fails on one app only, which is a miserable thing to debug.\n\nBefore shipping, run the full gate:\n\n```sh\nbunx --bun pikku all --tsc-summary --fail-on-warn\nbunx --bun pikku validate\nbunx --bun pikku knowledge validate\nbunx --bun pikku scenario run local --spawn --coverage\nbunx --bun pikku scenario run local --spawn --run browser\nbun run build # every frontend workspace, type-checked\n```\n\nKeep `--coverage` on the release run even though you have been reading it per\nmilestone (§7a). Each of those readings only covered the functions that\nmilestone added; this is the first time the whole surface is measured at once,\nand it is where a function orphaned by a later refactor shows up.\n\n**The last two lines are not optional, and one of them is easy to talk yourself\nout of.** The server-side pass proves the functions; it renders nothing. The\npages are client-rendered, so a component that throws still returns HTTP 200\nwith an empty shell — the same trap §6 warns about, and the release gate is\nexactly where it gets shipped past. Run the browser pass, and run it **for every\nenvironment in `pikkufabric.config.json`**, not just the first:\n\n```sh\nbunx --bun pikku scenario run local-admin --spawn --run browser\n```\n\n`bun run build` is what type-checks each frontend (each app's `tsc` script runs\n`--noEmit`). `pikku all --tsc-summary` covers the functions package; it does not\nreach into the apps, so a broken screen passes every pikku command and fails on\nthe deploy.\n\n`pikku all --security --fail-on-error` additionally runs the data-classification\nlint over function return types, catching a `Pii`/`Secret` field that leaks\nthrough an exposed function. Expensive, so run it before a release rather than on\nevery save — but run it.\n\n## Staying Fabric-ready\n\nEverything above is open source. This is the contract that keeps\n`pikku fabric init` a one-command import later, instead of a migration.\n\n- **`pikkufabric.config.json` describes reality.** Every app has an entry with\n the right `cwd`, `port`, `kind` and `dev.command`; exactly one is `primary`;\n `serves` and `personas` name real personas from the personas section. Leave `projectId` as\n `__PROJECT_ID__` — that placeholder means \"unlinked\", and linking the project\n writes the real one. Do not invent a value to make it look configured.\n- **One `definePersonas` call**, every persona reachable through exactly one\n frontend. Fabric materialises these as its virtual users; a persona nobody\n serves imports as a person with no way in.\n- **`knowledge/` passes `validate`, with every milestone at `built`.** This is\n the part Fabric itself reads and continues from.\n- **Every `built` milestone passes `pikku knowledge plan progress`.** A note that\n says `built` is a claim; the plan reconciled against the generated meta is the\n check. Anything the first pass still owes is either built now or deferred with\n its reason on the record; anything the check calls a problem — something that\n exists and does not do what was planned — is fixed, whatever pass it came from,\n because deferring it defers a hole rather than the work.\n- **Every milestone has a passing scenario**, including its refusals.\n- **Permissions live in the `permissions` field**, not in function bodies and not\n in the frontends. A check hidden in a component does not survive a new client.\n- **Nothing hardcodes a host, a port, or a `process.env` read inside a\n function.** Secrets go through `defineSecret` and the injected `secrets`\n service. This is the single most common reason a working local project fails\n its first deploy — on any platform.\n- **Generated files stay generated.** No hand edits to `.pikku/`, `*.gen.*`, or\n the SDK.\n- **`pikku validate` is clean**, and `pikku all` has no critical diagnostics.\n\nWhat you deliberately do NOT do: run any `pikku fabric` subcommand, add a card,\nor link a project. None of it is needed to build, test, critique, or deploy — it\nis needed the day the user wants Fabric to host and build it, and on that day, if\nthis section holds, that day is one command long.\n", "pikku-build/references/theming.md": "# Authoring the theme\n\nRead this at the design step, once you have a direction to turn into a theme —\nwhether the user described one in words, handed you a reference, or ran their own\ndesign step whose output you are implementing.\n\nNothing in the open-source toolchain authors a theme for you. Fabric has the\n`fabric-theme` tool; without it, hand-authoring is the route, and it is a small\njob done in JSON.\n\n## Where the look lives\n\n```\npackages/mantine-theme/\n themes/\n default.json # \"Neutral\" — the shipped scaffold\n index.ts # registers each theme JSON by id\n active.json # { \"id\": \"default\" } — which one is live\n index.ts\n```\n\nEach theme JSON has two halves — `brand` (colours, fonts) and `structure`\n(radius, shadows, spacing, per-component `defaultProps`). To give the product an\nidentity, add `themes/<name>.json`, register it in `themes/index.ts`, and point\n`active.json` at its id.\n\n`themes/index.ts` carries a `Generated by the fabric-theme tool — do not edit by\nhand` banner. **That instruction is about Fabric's generator, not about you.**\nWithout that tool, hand-authoring is the only route, and the file is a three-line\nregistry. Edit it, and replace the banner with a line saying the themes here are\nhand-authored — otherwise the next agent reads the warning and leaves the app on\nNeutral.\n\nTurning §1's answer into a theme:\n\n- **Colour before anything else.** `brand.colors.primary` plus\n `structure.primaryShade` and `autoContrast` carry most of the identity;\n `@mantine/colors-generator` (already a dependency) expands one hex into a full\n scale.\n- **Fonts are the other half of the register**, and the half people skip. A serif\n heading font against a neutral body is a different product from the system\n stack, and it is one field.\n- **`structure.radius` and `structure.shadows` set the temperature.** Sharp\n corners and flat surfaces read technical; large radii and soft shadows read\n consumer. Neutral's `md: 10px` is the middle of the road on purpose.\n- **`structure.components` defaultProps is where a design decision becomes\n automatic** — `Card` with `withBorder` everywhere, `NavLink` as `light`. Put\n the repeated decision here rather than on every instance.\n- **`defaultColorScheme` and `darkColors` are a real choice**, not a toggle to\n leave alone. A tool people live in all day is often better dark by default.\n\nThen **write the direction into `knowledge/decisions/design/`** — the words the\nuser gave you, what you chose, and what it rules out. The JSON records what the\ntheme is; only the note records why.\n\n**Set the theme once, don't hardcode colours per component.** A screen full of\ninline `color=\"blue\"` and one-off hex values is why apps look templated. Change\nthe theme, not the components — and keep it theme-aware for light and dark.\n\nWith two apps, **share the theme package and vary the register, not the\npalette.** A back-office can be denser and more tabular; a customer-facing app\ncan be roomier and warmer — that is `structure` and layout, not a second `brand`.\nTwo unrelated colour schemes read as two products from two companies.\n\n\n## If the user gave you no direction\n\nNeutral is a legitimate answer for an internal tool. But say so out loud when you\nhand the work over — an unremarked default reads as a choice, and the user will\nassume someone decided.\n", "pikku-build/SKILL.md": "---\nname: pikku-build\ndescription: >-\n Use to build on Pikku — turning a fresh scaffold into a working app (quick spike, real product,\n or a showcase that exercises every surface), adding a feature to an app that already exists, and\n the one-off cleanup right after a template is cloned. Covers the knowledge base, personas and\n roles, milestone planning, the scenario that proves each one, theming, multi-app layouts and\n deploying. TRIGGER when: the user asks for an app to be built on Pikku, a freshly scaffolded\n project needs turning into a product, the user asks to add a feature or wire up a new endpoint\n in a working app, or a template was just cloned or scaffolded. DO NOT TRIGGER when: the user\n asks for a one-off edit to an existing function, asks about Pikku concepts (use pikku-concepts),\n or wants one specific surface explained rather than built (use that surface's skill).\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 rm *), Bash(git mv *), Bash(git log *), Bash(git branch *), Bash(yarn pikku fabric report *), Bash(npx --no pikku fabric report *)\nargument-hint: '[feature description]'\ninstallGroups: [core]\n---\n\n# Build on Pikku\n\n## Which mode\n\n| The situation | Read |\n| --- | --- |\n| A template was just cloned or scaffolded, and the tree still looks like one | `references/post-clone.md` first, then come back |\n| A real product, meant to be picked up by someone else | `references/app.md` — the default |\n| A spike, a throwaway demo, an idea nobody has committed to | `references/quick.md` |\n| A showcase meant to exercise every Pikku surface | `references/platform.md`, which is a delta on top of `references/app.md` |\n| A feature added to an app that already has its knowledge base and milestones | `references/feature.md` |\n\n**App is the default.** A small or toy-sounding app does not make it Quick;\nonly an explicit signal of speed or throwaway-ness does. Platform is not \"App\nplus more effort\" — it is App plus a deliberate surface checklist, so read the\nbase first and follow it in full rather than blending the two into one plan.\n\nThe supporting references belong to whichever mode sends you to them:\n`references/multi-app.md` (a second frontend), `references/theming.md`\n(authoring the theme), `references/ship.md` (deploying, and the Fabric-readiness\ncontract).\n\n## Bootstrap before anything else\n\n```sh\nbunx --bun pikku bootstrap\n```\n\nOnce, now — not later when you start building. It wires the `#pikku` alias the\ngenerated code depends on, and on a fresh scaffold **every command that touches\ncodegen fails until it has run**, including ones you would reasonably reach for\nwhile still planning. Those failures look alarming and are nothing but this.\n\n## What holds in every mode\n\n- **The branch and the diff are the contract.** There is no plan JSON. A\n reviewer sees real, compiled, working code: apply is a merge, reject is a\n `git branch -D`.\n- **Discover before editing.** `yarn pikku meta context --json` returns\n functions, wires, middleware, permissions, workflows, `capabilities` and\n `layout` in one call. Fall back to targeted `meta` commands only for a full\n schema or a workflow's steps.\n- **`metaLocale` in `pikku.config.json` is the language of authored meta** —\n every `description`, `title` and step `template` the console renders.\n Identifiers stay English whatever it says, and the product's own language\n lives in `messages/*.json`.\n- **`pikku all` is the gate.** Run it after touching functions, wirings or\n schemas, and treat its criticals as real.\n- **A milestone is planned by a different seat than the one that builds it.**\n The plan — tables, functions, wires, roles, scopes, screens, scenarios, in\n passes — is written through `pikku knowledge plan set` by `pikku-architect`,\n and `pikku knowledge plan progress` measures the build against it from the\n generated meta. A builder who writes its own plan is grading itself.\n\n## What NOT to do\n\n- **Do not skip ahead in App mode.** Knowledge, then people, then milestones,\n then one milestone at a time — planned, built, proven by a scenario, and\n closed against its plan before the next starts. The order is the method.\n- **Do not close a milestone your plan says is unfinished.** Build the missing\n item, or defer it with a reason through `pikku knowledge plan defer`. Never\n edit the plan to match what you built, and never drop an item silently.\n- **Do not let a Quick build be mistaken for a real one.** It skips\n `knowledge/`, milestone planning, design direction and refusal scenarios — say\n so out loud to the user when you finish, and point at the way out.\n- **Do not introduce a wire of a type whose `capabilities.<type>` is `false`**\n unless the user asked for it.\n- **Do not hand-edit generated files** — `.pikku/`, `*.gen.*` or the SDK. Fix the\n source and regenerate.\n- **Do not invent a role.** An invented role becomes invented screens; build only\n the roles the user named.\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-wiring` |\n| **Middleware** (Express/Koa-style) | `pikkuMiddleware` | `pikku-middleware` |\n| **Auth Guard / Auth Middleware** | `authBearer()` / `authCookie()` / `authApiKey()` | `pikku-auth` |\n| **Authorization / Permissions** | `pikkuPermission` / `pikkuAuth` | `pikku-auth` |\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-wiring` |\n| **Job Queue workers** | `wireQueueWorker` | `pikku-wiring` |\n| **Cron / Scheduled tasks** | `wireScheduler` | `pikku-wiring` |\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-services` |\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'] })\naddTagMiddleware('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/error'\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 Use FIRST in any Pikku codebase, before writing an import or reaching for another pikku skill.\n Covers the core mental model, function types, project structure, code generation and testing,\n and how to read `pikku doc` — the API surface of the pikku actually installed here, which also\n indexes which skill teaches each door. TRIGGER when: starting any Pikku task, about to import\n from `#pikku/*`, unsure whether an export exists or what its options are called, choosing which\n pikku skill to load, a build failed on an unknown import or option, or migrating an existing\n backend to Pikku. DO NOT TRIGGER when: the task is not a Pikku project.\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 `pikku doc --ai` for the installed API surface, and the relevant `pikku meta ... --json` for what this project has wired.\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\n## Ask The Installed Pikku, Don't Guess\n\nPikku generates `#pikku/*` imports per project and changes between versions. Anything you\nremember about its API may be from a different version than the one in this directory.\nEverything below is the mental model; `pikku doc` is the API surface, computed when the\ninstalled CLI was built. It needs no config and works outside a project.\n\n**Do not write an import, an export name, or an option key you have not seen in `pikku doc`.**\nA name that looks right and is not costs a full build cycle to discover. If the doc does not\nlist it, it does not exist here — do not reach into `node_modules` or `.pikku` for something\nthat will work anyway.\n\n### Start here, every time\n\n```\npikku doc --ai\n```\n\n≈480 tokens, giving the 20 `#pikku/*` doors grouped by the job they do, and beside each the\nskill that teaches it. Read that routing table as the index to every other pikku skill — it is\ngenerated from the installed version, so it never names a skill for a door that no longer exists.\n\nThen go one of two ways. For **what exists** — the exact export name, its options, its\nsignature — stay in the doc:\n\n```\npikku doc http one door: its exports, each with a signature or a key count\npikku doc wireHTTP one export: signature, every key with what it is for\npikku doc wireHTTP pikkuFunc several topics in one call, rather than one call each\n```\n\nFor **how it fits together** — composition, lifecycle, the generated client — load the skill\nthe routing table named. The doc lists keys; it does not teach patterns.\n\nOn a door screen, `N keys — pikku doc X` means a second call buys you something; an inlined\nsignature means it does not. Error classes carry the HTTP status they are registered with,\nwhich is what decides whether a thrown error becomes a 409 or a 500.\n\n### Two things the doc will not give you\n\n- **Worked examples are sparse.** Most exports show a signature and keys, not usage.\n- **`pikkuFunc` lists keys that belong elsewhere.** `before`, `after`, `skip`, `surfaces` and\n `requiresActor` apply only to scenarios; `workflowQueued`, `workflowRetries` and\n `workflowTimeout` only to a workflow step. One shared config type offers all of them to\n every function — each key says which it belongs to.\n\n`pikku doc` needs `@pikku/cli` 0.12.115 or newer. On an older pin, fall back to the door's\nskill and `pikku meta --json`, and do not guess at names the doc would have given you.\n\n## The CLI commands\n\n`pikku doc` is the API surface — the `#pikku/*` exports. It does **not** list\ncommands, so this table is where they exist. `pikku <command> --help` has the\nflags; the \"Read\" column is the skill that teaches the thing, where one does.\n\n**Generating**\n\n| Command | What it does | Read |\n| ------------------------------------------ | ---------------------------------------------------- | ----------------------------- |\n| `all` | Everything: types, schemas, wirings, clients | this skill |\n| `bootstrap` | Type files only (the setup phase) | this skill |\n| `schemas` | JSON Schemas for function input/output types | this skill |\n| `fetch` / `websocket` / `rpc` / `realtime` | One client each, when you do not want `all` | `pikku-wiring`, `pikku-react` |\n| `react-query` / `tanstack-start` | React Query hooks; the TanStack Start `makeApi` shim | `pikku-react` |\n| `queue-service` | The queue service wrapper | `pikku-wiring` |\n| `openapi` | An OpenAPI spec from the HTTP routes | — |\n| `nextjs` | Next.js backend and HTTP wrappers | `pikku-deploy` |\n| `new` | Scaffold a function or wiring | `pikku-wiring` |\n| `enable` | Turn a Pikku feature on | `pikku-build` |\n| `import` | Import workflows from another system | `pikku-n8n-import` |\n\n**Running**\n\n| Command | What it does | Read |\n| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------- |\n| `dev` | Local dev server, all services wired, watch + HMR | `pikku-build` |\n| `serve` | Bundled bun/node runner — no watch, no codegen | `pikku-deploy` |\n| `watch` | Regenerate on file change, without a server | — |\n| `scenario list\\|run` | Scenarios as e2e tests and health checks | `pikku-scenario` |\n| `persona run` | A declared persona as a model-driven virtual user against a stage | `pikku-scenario`, persona-run reference |\n| `persona list\\|sync\\|secret` | Who is declared; what an environment will provision; minting their credentials | `pikku-scenario`, persona-run reference |\n| `db` | Local development database | `pikku-kysely` |\n\n**Inspecting and evolving**\n\n| Command | What it does | Read |\n| --------------------- | ----------------------------------------------------------------------- | ---------------------------- |\n| `doc` | The installed API surface | this skill |\n| `meta` / `info` | What the project declares, machine- and human-readable | `pikku-meta` |\n| `validate` | Every check that applies — app structure, an addon's published file set | `pikku-build`, `pikku-addon` |\n| `versions` / `semver` | Contract hashes, breaking-change detection, the release semver | `pikku-meta` |\n| `audit` / `update` | Advisories; which `@pikku/*` can move and what peers that needs | `pikku-meta` |\n| `scopes` / `roles` | Declared authorization scopes; roles from `defineSystemRole` | `pikku-auth` |\n| `knowledge` | The knowledge base — what this app is, in its users' language | `pikku-knowledge` |\n| `emails` | Email template generation | `pikku-emails` |\n\n**Shipping, and the CLI itself**\n\n| Command | What it does | Read |\n| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |\n| `deploy` | Deploy to cloud infrastructure | `pikku-deploy` |\n| `fabric` | PikkuFabric: login, link, deploy, domains, secrets, logs | `pikku-fabric` |\n| `binary` | Compile an entrypoint to a native binary (`bun build --compile`) | — |\n| `dist` | Copy what `tsc` cannot emit — `.gen.json` meta, hand-authored `.d.ts` — into the build output. Run it after `tsc`, as a package's build script | — |\n| `login` / `logout` / `whoami` | The CLI's session against a pikku server | — |\n| `skills` | Install these skills into an agent (Claude Code, opencode, pi) | — |\n\n`-c/--config`, `--log-level`, `--json` and the filter flags are **global\noptions**, not commands — they attach to the generating commands above.\n\nA dash means no skill covers it beyond this line. `--help` is then the whole of\nit — which is a reason to read `--help` rather than to assume the command does\nwhat its name suggests.\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 ├── pikkuAgent → 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 and authorization checks → `pikku-auth`, a separate install; 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 — prose, so it follows `metaLocale` in\n // pikku.config.json (default `en`). The identifier does not; see\n // \"What Language You Write In\".\n title?: string, // Human-readable name\n description?: string, // What the function does\n version?: number, // Contract version (see pikku-meta)\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-wiring)\n remote?: boolean, // Allow remote RPC calls\n mcp?: boolean, // Expose as MCP tool (see pikku-wiring)\n readonly?: boolean, // Declares the function performs no writes\n deploy?: 'serverless' | 'server' | 'auto',\n\n // Authorization — see pikku-auth\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-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**Only `pikku dev` and `pikku serve` invoke these hooks.** No deploy runtime does, so anything a Workers or serverless stage needs done cannot live here — put it on the request path that needs it, guarded by a cheap check.\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- one directory per wiring (`function/`, `http/`, `workflow/`, …), each with an\n `index.ts` reached as `#pikku/<name>` — typed function factories and wiring\n functions, split so an app pulls in only the wirings it uses\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-middleware)\n├── permissions.ts # Permission definitions (see pikku-auth)\n└── .pikku/ # Generated (gitignored)\n ├── function/ # #pikku/function\n ├── http/ # #pikku/http\n ├── pikku-fetch.gen.ts\n └── pikku-bootstrap.gen.ts\n```\n\n## What Language You Write In\n\nThree different things in a Pikku project have a human language, and they are\n**not** the same language. Collapsing them is the mistake this section exists to\nprevent, and it has already shipped in a real product — the failure is at the\nbottom.\n\n| Axis | What it covers | What decides it |\n| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Branch names and commit messages. | Nothing. **Always English.** There is no setting. |\n| **Meta** | The prose authored _inside_ the code: `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role/persona descriptions. | `metaLocale` in `pikku.config.json`. Defaults to `en`. |\n| **Product UI** | Every string the app shows a user. | `messages/<locale>.json`, with `active.json`'s `defaultLocale` choosing what a first-time visitor opens in. |\n\n### Identifiers are English, and nothing changes that\n\nNot the product's market, not the team's working language, and **not `metaLocale`**.\nA German medical practice, an Arabic marketplace and a Japanese logistics tool\nall get `getOverview`, `AttentionStripe`, `case`, `event`.\n\nThis is not linguistic preference, it is mechanics. Identifiers are the surface\nevery other tool binds to: the generated `#pikku/*` clients, `pikku info` and\n`pikku meta`, the RPC map a scenario's `actor.invoke` is typed over, the\ngenerated SQL types, every skill and every agent that ever picks the project up.\nA `vorgang` table types as `Vorgang` in Kysely and reads as noise to everyone who\ndid not name it, and unlike a string it cannot be translated later — renaming an\nidentifier is a migration, not an edit.\n\n### Meta follows `metaLocale`, and that is what the field is for\n\n```json\n{ \"metaLocale\": \"de\" }\n```\n\nMeta is the one part of a project the **Pikku Console** renders back to a human.\nA team reviewing their own functions, features and scenario reports in the\nConsole is reading meta and nothing else, so a team whose working language is\nGerman should be able to read their Console in German. That is the entire reason\nthe field exists.\n\nRead it before you author meta, and write descriptions, titles and templates in\nit. Absent, it is `en`. It is a BCP-47 tag (`en`, `de`, `pt-BR` — a hyphen, not\nan underscore), and the CLI rejects anything else by name.\n\n`metaLocale` is **not** licence to rename anything. `metaLocale: \"de\"` buys a German\n`description: 'Zeigt die Arbeitsliste'` on a function still called\n`getWorklist`.\n\n### Product UI language lives in the catalogue, and only there\n\nWhat the app says to its users is a translation concern, not a code concern. It\nbelongs in `messages/<locale>.json`; `pikku-i18n` owns the details. The one rule\nworth repeating here: **`baseLocale` in `project.inlang/settings.json` stays\n`en`.** It names the message _source_ — the catalogue every other language is\ncloned from and translated against — so a project that sets it to anything else\nhas no English catalogue to translate from and can never gain a second language\nwithout re-authoring every key.\n\n### The failure this comes from\n\nAn agent was asked to build a doctor's portal for a German practice. The brief\nsaid \"the entire UI is German, no English strings visible anywhere\". The agent\nread one sentence about the product's users as an instruction about the\ncodebase, and produced:\n\n- `project.inlang/settings.json` with `baseLocale: \"de\"` and `locales: [\"de\"]`,\n no `en.json` at all — which silently broke `--add-locale` forever\n- RPC functions `getUebersicht` and `getPatientendetail`\n- React components `Zeitstrahl` and `AufmerksamkeitStreifen`\n- database tables `vorgang` and `ereignis`, with German columns\n\nEvery one of those is wrong, and the brief was satisfied by none of them: a\nGerman UI needs German _messages_. What that project actually wanted was three\nsettings, each on its own axis:\n\n```jsonc\n// project.inlang/settings.json — the message source stays English\n{ \"baseLocale\": \"en\", \"locales\": [\"en\", \"de\"] }\n\n// apps/app/src/i18n/active.json — what a first-time visitor opens in\n{ \"defaultLocale\": \"de\" }\n\n// pikku.config.json — the language the team reads their Console in\n{ \"metaLocale\": \"de\" }\n```\n\nIdentifiers stay English throughout. When a brief tells you the product speaks a\nlanguage, it is telling you about axis three and nothing else.\n\n## Environment Variables\n\nNever use `process.env` inside Pikku functions. Use the `variables` service (see `pikku-services`):\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-deploy/references/azure.md": "# Azure Functions\n\n```bash\nyarn add @pikku/azure-functions @azure/functions\n```\n\n`createAzureHandler(factories, handlerTypes)` is the entry point, returning\n`{ http?, queue?, timer? }` for the handler types you ask for.\n`createAzureWorkerHandler(factories)` is `createAzureHandler(factories,\n['fetch'])`. `factories` is `{ createConfig, createSingletonServices,\ncreatePlatformServices? }`; services are built from `process.env` and cached in\nmodule scope across invocations of the same instance.\n\n**Channels do not work on Azure.** `createAzureWebSocketHandler` is a stub whose\n`negotiate` always answers `501 WebSocket via Azure Web PubSub not yet\nimplemented` — do not plan a deployment around it.\n\nTwo naming traps: the logger is `AzInvocationLogger`, not\n`PikkuAzFunctionsLogger`; and `PikkuAZTimerRequest(context, data)` accepts the\ncontext argument and ignores it.\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({ createConfig, createSingletonServices }, [\n 'fetch',\n 'queue',\n '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/references/cloudflare.md": "# Cloudflare Workers\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 — do not hand-roll this\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\nBeyond building `LocalVariablesService` / `LocalSecretService` and caching the\nresult, it calls `setSingletonServices()` — and the core runners (`fetchData`,\n`runQueueJob`, `runScheduled`) resolve services through that global slot, _not_\nthrough the value you were returned. A setup function that only returns the\nservices leaves every request throwing \"Singleton services not initialized\" as a\nCF `1101`. It also stashes the env via `setCloudflareEnv`, which\n`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 `controller.cron`\nand **returns after the first match**. Two tasks sharing one cron expression\nmeans only one of them ever runs — give each its own expression, or invoke\n`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/references/express.md": "# Express\n\n```bash\nyarn add @pikku/express\n```\n\n## Standalone server\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\nThe config extends `CoreConfig` with `port`, `hostname`, an optional\n`healthCheckPath`, an optional `limits` map and an optional `content`\n(`LocalContentConfig`) for static assets and file uploads. `app: Express` is\nexposed for custom middleware, and `getHttpServer()` returns the underlying\n`http.Server` — to attach a WebSocket server, say — but throws before `start()`.\n\n`enableStaticAssets()` serves `content.localFileUploadPath` under\n`content.assetUrlPrefix`, and `enableReaper()` adds a `PUT /reaper/*path` upload\nsink for local development, path-traversal checked and bounded by\n`content.sizeLimit` (default `1mb`). Both throw when `content` is unset.\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`stop()` throws if the server was never started. `enableExitOnSigInt()`\ninstalls a SIGINT handler that stops the singleton services, then the server,\nthen exits 0.\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 `req.body`,\nso a body parser and `cookie-parser` must be registered before it, and\n`maxBodySize` alone will not stop an oversized request that your parser already\naccepted. Unmatched requests fall through to `next()` (unless `respondWith404`\nis set), so Pikku's routes coexist with your existing ones; a streaming response\nis the exception and does not call `next()`.\n", "pikku-deploy/references/fastify.md": "# Fastify\n\n```bash\nyarn add @pikku/fastify\n```\n\n## Standalone server\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\nThe config extends `CoreConfig` with `port`, `hostname` and an optional\n`healthCheckPath`. `app: FastifyInstance` is exposed for direct access.\n\n`enableCors` exists on the class but **throws `Method not implemented.`** —\nunlike the Express server. Register `@fastify/cors` on `app` yourself before\n`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/references/lambda.md": "# AWS Lambda\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 store.\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/references/nextjs.md": "# Next.js\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`get`, `post`, `patch`, `del` and `rpc` read `next/headers` cookies and headers,\nso they force the component dynamic. `staticGet`, `staticPost` and `staticRPC`\nhave no request context and are safe for 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\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 response\n 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/references/uws.md": "# uWebSockets.js\n\nHighest-throughput option among Pikku's runtimes. Handles both HTTP and\nWebSocket 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\nThe config extends `CoreConfig` with `port`, `hostname` and an optional\n`healthCheckPath`. `app: uWS.App` is exposed for direct access.\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\nFor a WebSocket-only server on the `ws` library instead, see `ws.md`.\n", "pikku-deploy/references/ws.md": "# ws (WebSocket only)\n\n`@pikku/ws` connects Pikku's channel system to a Node.js WebSocket server built\non the [ws](https://github.com/websockets/ws) library. Use it for a\nWebSocket-only server; for HTTP and WebSocket on one port see `uws.md`, and when\nthe WebSocket server shares a port with an existing HTTP app see `express.md` or\n`fastify.md`.\n\n```bash\nyarn add @pikku/ws ws\n```\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 { DEFAULT_WS_MAX_PAYLOAD, 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({\n noServer: true,\n maxPayload: DEFAULT_WS_MAX_PAYLOAD,\n})\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\nprocess.on('SIGINT', async () => {\n await stopSingletonServices()\n wss.close()\n server.close()\n process.exit(0)\n})\n```\n\n## `noServer: true` is required, not stylistic\n\nThe handler listens for the HTTP server's own `upgrade` event, opens the channel\n— running Pikku's middleware chain, auth and CORS against the upgrade request\nfirst — and only then calls `wss.handleUpgrade`. A `WebSocketServer` bound to the\nserver directly would take the socket before any of that ran.\n\nAn upgrade the channel rejects gets the socket destroyed, and an auth failure is\nwritten as a real HTTP response on the raw socket rather than a silent drop.\n\n## Services and the event hub\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.\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", "pikku-deploy/SKILL.md": "---\nname: pikku-deploy\ndescription: >-\n Use when deploying a Pikku app to a runtime — Express, Fastify, uWebSockets.js, the `ws` library,\n Next.js, AWS Lambda, Cloudflare Workers or Azure Functions. Covers the bootstrap every runtime\n shares, choosing between them, and the behaviour that differs: which accept\n `RunHTTPWiringOptions`, how each one runs scheduled tasks, and where CORS and health checks live.\n TRIGGER when: writing or debugging `start.ts` / a worker entry / a Lambda handler, code imports\n `@pikku/express`, `@pikku/fastify`, `@pikku/uws`, `@pikku/ws`, `@pikku/next`, `@pikku/lambda`,\n `@pikku/cloudflare` or `@pikku/azure-functions`, or the user asks how to serve, host or deploy a\n Pikku app. DO NOT TRIGGER when: defining functions or wirings with no runtime-specific code.\ninstallGroups: [core]\n---\n\n# Pikku 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\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: which\nruntime to pick, and what each one does differently once you have.\n\n## Pick a runtime\n\nTwo families, and the difference decides how you write the entry file.\n\n**Long-running servers** own a process. Services are built once at startup and\nlive in module scope for the life of the server.\n\n| Runtime | Package | Reach for it when |\n| --- | --- | --- |\n| Express | `@pikku/express` | An existing Express app, or you want static assets and upload handling |\n| Fastify | `@pikku/fastify` | An existing Fastify app, or you want its stricter defaults |\n| uWebSockets.js | `@pikku/uws` | Highest throughput, HTTP and WebSocket on one port |\n| ws | `@pikku/ws` | WebSocket only, attached to an `http.Server` you own |\n\n**Per-invocation runtimes** are handed a request and torn down. Services are\ncached in module scope across warm invocations, and the deploy codegen writes\nthat caching for you.\n\n| Runtime | Package | Reach for it when |\n| --- | --- | --- |\n| AWS Lambda | `@pikku/lambda` | API Gateway, EventBridge, SQS |\n| Cloudflare Workers | `@pikku/cloudflare` | Edge, Durable Objects for channels |\n| Azure Functions | `@pikku/azure-functions` | Azure hosting — note channels are not implemented |\n| Next.js | `@pikku/next` | Pikku behind Next routes, or RPC from Server Components |\n\nThen read the reference for the one you picked: `references/express.md`,\n`references/fastify.md`, `references/uws.md`, `references/ws.md`,\n`references/nextjs.md`, `references/lambda.md`, `references/cloudflare.md`,\n`references/azure.md`.\n\n## The bootstrap every runtime shares\n\nImporting the generated bootstrap registers your wirings; nothing routes without\nit. `createConfig` and `createSingletonServices` come from your own\n`services.ts`.\n\n```typescript\nimport './.pikku/pikku-bootstrap.gen.js'\nimport { createConfig, createSingletonServices } from './services.js'\n\nconst config = await createConfig()\nconst singletonServices = await createSingletonServices(config)\n```\n\nA long-running server takes it from there. A per-invocation runtime wraps the\nsame two calls in a memoised factory, because the module may be reused across\ninvocations — every `@pikku/*` serverless package ships those factories, and\nhand-written handlers are for the cases the deploy codegen does not cover.\n\n## What differs, and where it bites\n\n### `RunHTTPWiringOptions` is not accepted everywhere\n\n`maxBodySize`, `respondWith404`, `coerceDataFromSchema` and `bubbleErrors` reach\nthe request only on the runtimes that thread them through.\n\n| Runtime | Accepts options | Where an oversized body is actually stopped |\n| --- | --- | --- |\n| Express | `init(httpOptions)` | The `express.json` parser limit, fed by `maxBodySize` |\n| Fastify | `init(httpOptions)` | Fastify's own `bodyLimit`, set from `maxBodySize` |\n| uWS | HTTP handler only | The handler counts bytes itself and answers `413` |\n| ws | Yes, on the handler | `maxPayload` on the `WebSocketServer` |\n| Next.js | Only via your own `PikkuNextJS` handler | Next's own limits |\n| Lambda | **No** | API Gateway's payload limit |\n| Cloudflare | Partially — `runFetch(request, hibernation, options)` | The platform's limit |\n| Azure | **No** | Azure's request limits |\n\nOn Fastify, leaving `maxBodySize` unset keeps Fastify's stricter 1MB default\nrather than loosening it to Pikku's 10MB fallback. On uWS the chunks are dropped\nrather than concatenated, so an oversized request never accumulates in memory.\n\n### Scheduled tasks run differently on all three serverless runtimes\n\nSame `wireScheduler` declaration, three behaviours. This is the one most likely\nto produce a silent production bug.\n\n- **Cloudflare** matches `controller.cron` and **returns after the first match**.\n Two tasks sharing a cron expression means only one ever runs.\n- **Lambda** runs **every** task in the bundle, ignores the event, and logs\n rather than rethrows a failure — one bad task cannot stop the others.\n- **Azure** runs **every** task in the bundle, ignores both the timer argument\n and each task's own cron, and does **not** catch per-task failures — the first\n throw aborts the rest.\n\nWhere a runtime runs everything in the bundle, the deployment unit is what\ndecides which tasks fire, not the schedule you wrote. Reach for\n`runScheduledTask({ name })` when one deployment genuinely bundles several tasks\nthat must fire separately.\n\n### CORS and health checks are not uniform\n\n- **Express** registers the health check in the **constructor**, so it answers\n before any middleware and cannot be wrapped in auth. `enableCors` must be\n called before `init()`.\n- **Fastify** registers it in `init()`, so nothing answers before that runs. Its\n `enableCors` exists but **throws `Method not implemented.`** — register\n `@fastify/cors` yourself.\n- **uWS** registers it in `init()` and has **no** `enableCors`, no static assets\n and no `content` support at all.\n- Serverless runtimes have neither; the platform in front of them owns both.\n On Lambda, only `runFetchV2` echoes the request `Origin`, so v1 preflights\n fail in the browser unless API Gateway or CloudFront adds the header.\n\n### Channels need a shared store off a single process\n\nA long-running server can hold channel state in memory. Every per-invocation\nruntime cannot: `$connect` and `$default` are separate invocations, so\n`channelStore` must be a real shared store (`PgChannelStore` and friends).\nCloudflare instead keeps state in a Durable Object, and **Azure has no channel\nsupport** — `createAzureWebSocketHandler` is a stub that answers `501`.\n\n## What NOT to do\n\n- Do not hand-roll Cloudflare's `setupServices`. It calls\n `setSingletonServices()`, and the core runners resolve through that global\n slot rather than the value you were returned — a setup that only returns\n services leaves every request throwing \"Singleton services not initialized\" as\n a CF `1101`.\n- Do not discard what a Lambda WebSocket handler returns.\n `connectWebsocket` returns a complete `APIGatewayProxyResult`; answering a\n hardcoded `200` instead accepts every connection, including the ones your\n channel's auth rejected.\n- Do not bind a `WebSocketServer` to the HTTP server on `ws` or uWS.\n `noServer: true` is required, not stylistic — the handler performs the upgrade\n itself so middleware and auth run against the upgrade request first.\n- Do not assume a runtime rethrows. Express, Azure and Lambda's `runFetch` each\n swallow or flatten errors differently; the reference for your runtime says\n which.\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-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- `{{{verifyUrl}}}` — the same value **unescaped**. See below.\n\nLocale strings may themselves contain variables and partial-free placeholders, e.g.\n`\"subject\": \"{{inviterName}} invited you to join {{organizationName}}\"`. Locale files\nand `theme.json` ship alongside the templates, so they are expanded first and a subject\nof `{{t.invitation.subject}}` expands fully.\n\n## Escaping\n\nValues are HTML-escaped (`& < > \" '`) on the way into `.html` output, so a URL, a\ndisplay name or a font stack containing quotes lands inside its attribute instead of\nbreaking out of it. `.subject.txt` and `.text.txt` are plain text and are never escaped.\n\nRendering is **layered by trust**, and the layers do not leak into each other:\n\n- Partials are inlined first — a `data` value that happens to contain `{{> footer}}`\n is not an include.\n- `theme.*` and `t.*` are template-author input: expanded next, escaped, and allowed\n to contain further placeholders (up to 5 levels).\n- Everything else is caller data: substituted in **one pass**, escaped, and never\n rescanned — a `data` value containing `{{...}}` renders as those literal characters.\n\n`{{content}}` and partials are template-authored markup and stay raw. For a value you\ngenuinely want inserted as markup, use the explicit `{{{value}}}` form — it is opt-in,\nit bypasses escaping entirely, and it is only safe for HTML you control.\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 attachments: input.attachments,\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/references/debugging.md": "# 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-fabric/SKILL.md": "---\nname: pikku-fabric\ndescription: 'Build, convert and debug apps on the Pikku Fabric platform. Covers SQLite/libSQL database setup with Kysely, fabric project layout, deploy provider config, `fabric.config.json`, the pikku-verify workflow, and reading logs, traces and metrics from a deployed stage. TRIGGER when: user is working on a Fabric-hosted Pikku project, converting an app to Fabric format, asking about Fabric deployment, database or project conventions, asking about a `pikku fabric validate` finding including app-missing-actor-quick-login, or a deployed stage is erroring, timing out or behaving differently than local (\"why is prod failing\", \"check the logs\"). DO NOT TRIGGER when: user is working on a generic (non-Fabric) Pikku deployment — use pikku-deploy instead — or the failure reproduces locally, which is where to debug it.'\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-wiring`, `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 **local dev data only**: enough rows that a fresh dev database isn't an\nempty app. Nothing else ever runs it. A deployed stage applies `db/<engine>/*.sql`\nand stops there — reset refuses `NODE_ENV=production` and refuses a database\noutside the runtime directory, and no deploy step reaches for the seed file.\n\nSo the test is not \"is this row realistic?\", it is **\"would the app be broken\nwithout it in production?\"** If yes, it is configuration and belongs in a\nmigration, however much it looks like sample data. A venue and its rooms, a\nproduct catalogue, a tenant, a country list, the organization the whole\ndeployment hangs off — all configuration. Accounts and role grants are\nprovisioning: the fabric plugin's `personas`, or a migration. What is left\nover is the seed's job — the bookings, orders and messages a demo needs and a real\nenvironment starts without.\n\nGet this wrong and it hides: the app is perfect locally, where reset has just\nrun, and every deployed environment comes up with empty tables. The signature is\na stage whose pages return 200 — the shell renders fine — while its first data\nread throws `no result` or a foreign-key violation on a row the seed was\nsilently supplying.\n\nA Better Auth app has a second constraint: the plugins you enable (`pikkuBan()`,\n`pikkuActor()`, …) 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.\n\nThat pass is a smoke check. Anything you would otherwise verify by hand-driving a\nbrowser tool belongs in a scenario's browser step, run with\n`pikku scenario run local --spawn --run browser` — a browser session you steered\nyourself proves nothing that re-runs.\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 apply --production -y\n```\n\nThe branch is positional and defaults to the checked-out one, and `-y` is the\nshort form of `--auto-approve`, so a one-shot deploy is:\n\n```bash\npikku fabric deploy apply -y # the branch you are standing on\npikku fabric deploy apply my-branch -y # a named one\n```\n\n`-y` answers the prompts and nothing more. It does **not** approve migrations\nthat drop or rewrite data — that stays `--allow-destructive`, typed out on\npurpose.\n\nInferring the branch is safe because the git safety check refuses any branch\nwithout an upstream or out of sync with it, so it cannot ship an unpushed\ncommit; the branch it picked is printed before the build starts. A detached\nHEAD is refused by name rather than travelling on as a branch called `HEAD`.\n\nThere is no `deploy plan` subcommand — `apply` runs the same auth, git-safety\nand ref resolution itself, and fabric produces the real plan server-side.\n\n`apply` confirms before deploying, and with no TTY to ask — CI, an agent shell —\nit refuses rather than hangs. `--auto-approve` (`-y`) supplies that confirmation;\ndrop it only when a human is at a real terminal.\n\n`apply` waits for a terminal state and exits non-zero unless the deployment went\nlive. `--detach` opts out — it queues the deploy, prints the deployment id and\nreturns 0, which tells you nothing about whether it worked:\n\n| exit | meaning |\n| ---- | ----------------------------------------------------------------------- |\n| 0 | live (or queued, under `--detach`) |\n| 1 | the command could not run — not logged in, unsafe git state, bad flags |\n| 2 | the deployment failed, errored, timed out server-side, or was cancelled |\n| 3 | the deployment is blocked and nothing the CLI can do will unblock it |\n| 4 | the wait hit `--timeout` with the deployment still in flight |\n\nFabric parks every deploy at a gate after the plan phase (`status: suspended`).\nWhy it parked is the whole story, and it is `statusReason`, not `status`:\n\n- `awaiting_approval` — the plan is fine, a human has to publish it.\n `-y` does that; without it you get exit 3 and the command to run.\n One exception: if fabric marked any pending migration **destructive** — a\n drop, a truncate, a rewrite — `-y` alone declines and exits 3,\n because a standing yes was given before anyone knew the plan dropped a table.\n The CLI lists the migrations and fabric's reasons; `--allow-destructive`\n accepts them for that deploy, and `-y` implies it.\n- `needs_config` — a declared secret or variable has no value covering the\n stage. The CLI names them. `-y` will **not** force this through;\n set the values and re-attach — `pikku fabric secrets set <name>` for a\n declared secret, `pikku fabric variables set <name> --value <v>` for a declared\n variable. They are separate stores: a secret is sealed to the stage and cannot\n be read back, a variable is stored plainly and can (`variables get`). `set`\n reads the value as JSON when it parses, so `--value true` is the boolean on a\n stage exactly as it is from `.env`, and `--value '\"true\"'` is the string.\n- `needs_attention` — the plan is red. Nothing to approve.\n\nThe wait defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout\nit prints the deployment id and the re-attach command rather than lying about\nthe outcome.\n\nSplitting kick-off from waiting across two CI jobs is the reason\n`--deployment-id` exists, and what `--detach` is for — the first job here has to\nreturn the id and exit rather than wait:\n\n```bash\nid=$(pikku fabric deploy apply --production -y --detach --json | jq -r 'select(.event==\"result\").deploymentId')\n# …later, in another job…\npikku fabric deploy apply --deployment-id \"$id\" -y\n```\n\n`--deployment-id` skips the git safety check entirely (the deployment already\npins a sha, and the checkout is allowed to have moved on) and refuses to be\ncombined with a branch or `--production`, which would let the two disagree.\n\nUnder `--json`, the wait emits one NDJSON event per line — `created`/`attached`,\n`status` on each transition, `blocked`, `approved` — and the last line is the\nterminal result object, tagged `\"event\": \"result\"`.\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### `app-missing-actor-quick-login-<app>`\n\nThe `fabric validate` finding people most often misread. It fires when an app has\na **login screen** but no dev actor switcher, and it is not a style nit: a sandbox\nreviewer has no seed password, so without the control they are locked out of the\napp they were asked to look at.\n\nSatisfy it with `<DevActorSwitcher />` from `@pikku/mantine/dev`, or with your\nown UI built on `useDevActors()` from `@pikku/react` — validate accepts either\ncall site as evidence, so custom rendering passes. See **pikku-react** for the\nprops and **pikku-scenario** for where the actor list comes from.\n\nThe validator also accepts the shapes that predate the package — a hand-rolled\n`signInAsActor()` or a literal `POST /auth/sign-in/actor` — so an older app does\nnot fail the build. **Treat that as a grace period, not the target: migrate those\nto `<DevActorSwitcher />`.** The hand-copied version is exactly the duplication\nthe package exists to remove, and the copies drift — the ones that prompted this\nhad already diverged on the `import.meta.env.DEV` gate that keeps the shared\nsecret out of production bundles.\n\nDo **not** satisfy it with Better Auth's `/dev/quick-login`. That is a different\nendpoint with a different purpose — one fixed admin, not the declared personas —\nand it does not clear this rule.\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/error`.\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- **Identifiers are English** — functions, components, types, files, database tables and columns, in every app whatever market it serves. The team's language is `metaLocale` in `pikku.config.json` and reaches `description`/`title`/`template` only; the app's language is the message catalogue, where `baseLocale` stays `en` and `defaultLocale` decides what a visitor opens in. `pikku fabric validate` warns (`app-base-locale-not-english-<app>`) when an app repoints its base.\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` — plus `metaLocale` if the team does not work in English, which is the language every `description`, `title` and step `template` is then authored in.\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\n## A deployed stage misbehaving\n\nReproduce locally first — a deployed stage adds cost and latency to every\niteration, and a failure that reproduces locally is a local debugging problem.\nWhen it only happens deployed, read `references/debugging.md`: start from\n`errors` rather than `logs` (they are already filtered and carry the traceId),\nfollow one trace end to end before forming a theory, and confirm the fix against\nthe same stage. A deploy that *failed* is a build or config problem and belongs\nabove, not there.\n", "pikku-i18n/references/enum-labels.md": "# 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 =\n '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\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({\n project: './project.inlang',\n outdir: './src/paraglide',\n }),\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-i18n/references/messages.md": "# 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. **`baseLocale` stays `en` whatever language the product speaks** — see [The product's language is not the code's language](#the-products-language-is-not-the-codes-language), which is the first thing to read if the brief says the app is not in English.\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 product's language is not the code's language\n\nA brief that says \"the entire UI is German, no English strings visible anywhere\"\nis a statement about **one** of three separate things, and reading it as a\nstatement about the codebase is the single most expensive mistake available in\nthis skill. Three axes:\n\n| Axis | What it covers | What sets it |\n| --------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |\n| **Identifiers** | Function, component, type and file names; database tables and columns | Nothing — always English, no setting |\n| **Meta** | `description` / `name` / `title` / `template` authored inside the code, which the Pikku Console renders | `metaLocale` in `pikku.config.json`, default `en` |\n| **Product UI** | Every string the app shows a user | `messages/<locale>.json` + `defaultLocale` — **this axis only** |\n\nA non-English product moves the third row and nothing else.\n\n### `baseLocale` stays `en`\n\n`baseLocale` in `project.inlang/settings.json` does not mean \"the language the\napp is in\". It names the message **source** — the catalogue every other locale is\ncloned from and translated against. Setting it to the product's language looks\nlike it works, because the app does come up in that language, and then:\n\n- there is no `en.json`, so `--add-locale` has no catalogue to translate from\n- the app can never gain a second language without re-authoring every key\n- a message missing from a locale falls back to a catalogue nobody wrote\n\nThe setting that actually decides what a first-time visitor sees is\n`defaultLocale`, held in `apps/app/src/i18n/active.json` in the Fabric app\ntemplate and read by `src/i18n/config.ts`. It is deliberately a separate file\nfrom `settings.json` for exactly this reason — the source language and the\nserved language are different questions.\n\nSo a German medical portal is **three** settings, not one:\n\n```jsonc\n// project.inlang/settings.json — the source catalogue is English\n{ \"baseLocale\": \"en\", \"locales\": [\"en\", \"de\"] }\n\n// apps/app/src/i18n/active.json — what a visitor opens in\n{ \"defaultLocale\": \"de\" }\n\n// pikku.config.json — the language the team reads their Console in\n{ \"metaLocale\": \"de\" }\n```\n\nIn the Fabric template both of the first two have a command, so you rarely edit\nthem by hand:\n\n```sh\nfabric i18n --add-locale de # adds \"de\" to locales, seeds messages/de.json from en.json\nfabric i18n --default-locale de # writes active.json — the app now OPENS in German\n```\n\n### The failure this is written from\n\nA real build, from this template. The brief said the UI was German; the agent\nset `baseLocale: \"de\"` with `locales: [\"de\"]` and no `en.json`, then carried the\nsame reading into the code — RPC functions `getUebersicht` and\n`getPatientendetail`, components `Zeitstrahl` and `AufmerksamkeitStreifen`,\nhelpers `datumDeutsch` and `voraussichtlichFertig`, database tables `vorgang`\nand `ereignis` with German columns.\n\nThe German UI it was asked for needed none of that. It needed German **values**\nin a catalogue whose keys and source stayed English. What it got instead was a\nproject that cannot add a second language and cannot be picked up by anyone who\ndoes not read German.\n\nIf you find a project in this state, say so plainly rather than working around\nit: `baseLocale` cannot be repointed without re-keying every message, so it is a\nmigration someone has to agree to, not a fix to slip in.\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 `references/enum-labels.md` 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`. **Leave `baseLocale` at `en`** — step 1 only works because there is an English catalogue to mirror.\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`.\n5. Only if the app should **open** in the new language rather than merely offer it: set `defaultLocale` (`active.json` / `fabric i18n --default-locale fr`). Adding a locale and changing the default are different asks — do the second only when asked.\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 set `baseLocale` to anything but `en`, whatever language the product speaks. It names the source catalogue, and a project without one can never add a language. Set `defaultLocale` instead.\n- Don't let a non-English UI reach the identifiers. Functions, components, types, files, tables and columns are English in every project; the product's language lives in `messages/*.json` and nowhere else.\n- Don't translate message **keys**. `auth__login__title` stays English in `de.json`; only the value changes.\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\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-i18n/references/rtl.md": "# Pikku RTL (Arabic + English)\n\nThis reference sits **on top of** `references/messages.md`. That one 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 `references/messages.md`. 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 `references/messages.md`. There is no `t()` and no i18next in a\n Pikku frontend — the string comes from `m.some__key()`.\n", "pikku-i18n/SKILL.md": "---\nname: pikku-i18n\ndescription: >-\n Use when writing user-facing text in a Pikku frontend, or making one speak another language.\n Covers Paraglide JS message functions compiled from messages/<locale>.json, adding a second\n language, generated enum-label maps with @pikku/paraglide, and right-to-left support for Arabic,\n Hebrew, Farsi and Urdu. TRIGGER when: scaffolding or editing a frontend and writing display\n text, asked to make copy translatable, adding a language, labelling an enum/status/role value,\n or asked to support RTL / mirror the layout. DO NOT TRIGGER for backend functions, error\n messages thrown from functions, or log output — none of those are display strings.\ninstallGroups: [client]\n---\n\n# Pikku i18n\n\n## Every visible string is a message\n\nNever hardcode display text: add a key to `messages/en.json` and render\n`m.the__key()`. This holds even in an app that will only ever ship English —\nthe messages are the seam a second language slots into, and the deploy pipeline\ntype-checks them, so an i18n mistake blocks the build rather than the release.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Writing copy, wiring Paraglide, or adding a language | `references/messages.md` |\n| Labelling an enum, status, kind or role value | `references/enum-labels.md` |\n| Adding Arabic (or Hebrew, Farsi, Urdu), or writing layout styles | `references/rtl.md` |\n\n## Three axes, and a brief usually means only one\n\n\"The entire UI is German\" is a statement about the product, not about the\ncodebase. Reading it as one about the codebase is the most expensive mistake\navailable here.\n\n| Axis | What it covers | What sets it |\n| --- | --- | --- |\n| **Identifiers** | Function, component, type and file names; tables and columns | Nothing — always English |\n| **Meta** | `description` / `name` / `title` authored in code, rendered by the console | `metaLocale` in `pikku.config.json` |\n| **Product UI** | Every string the app shows a user | `messages/<locale>.json` + `defaultLocale` |\n\n`baseLocale` stays `en` whatever language the product speaks — it names the\nmessage *source* catalogue every other locale is derived from, not the language\nthe app is in. Set `defaultLocale` instead.\n\n## Direction is one setting, not per-component work\n\nSet `dir` once at the document root from the active locale and the browser (and\nMantine) mirror everything — provided every custom style is flow-relative\n(`margin-inline-start`, `text-align: start`, Mantine `ms`/`me`) rather than\nphysical (`margin-left`, `text-align: left`, `ml`/`me`'s physical twins). Write\nlogical properties from the start even in an English-only app; that discipline\nis what makes an RTL language just another locale file.\n\n## What NOT to do\n\n- **Do not resolve a message key at runtime.** No `mKey('status.' + value)`, no\n `m['enum__' + x]()`, no key-string resolver. A computed key cannot be\n type-checked or tree-shaken, so a renamed message degrades to silent runtime\n text. Where the key is genuinely dynamic, map the discriminant to a message\n *function* — the map is checked, a string is not.\n- **Do not `asI18n()` a hardcoded English string.** `asI18n` exists to pass\n opaque server data (a name, a slug, an id) through the i18n gate. An enum value\n goes through its generated label map.\n- **Do not wrap `m` without a reason you can name.** `m.some__key()` already\n satisfies the `I18nNode` gate, so a plain re-export module adds nothing and\n costs per-message tree-shaking. Wrapping the namespace is only worth it when it\n buys a feature the gate cannot — debug masking of translated copy, say — and\n then the catalogue has to be small enough to ship whole.\n- **Do not translate message keys.** `auth__login__title` stays English in\n `de.json`; only the value changes.\n- **Do not edit or commit `src/paraglide/`, `i18n-enum.gen.ts` or `enums.gen.ts`.**\n Change the catalogue or the migration and regenerate.\n- **Do not fake RTL** with `flex-direction: row-reverse`, reversed DOM order, or\n per-locale layout branches. They double-flip the moment direction changes. DOM\n order is logical order; let `dir` decide the visual one.\n- **Do not reach for i18next or a runtime translation loader.** Paraglide's\n compiled functions are the whole delivery mechanism.\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` — or `milestone`, when the project's own `knowledge/index.md` names the section that way; `validate` accepts both, so follow the scaffold rather than this list. Then `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 `designing` → `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally. `designing` sits BEFORE `proposed`: the slice is written down but must not be built yet, because whoever is being shown its looks has not picked one. Only `proposed` is dispatchable, so the two cannot be one status without a slice being built out from under the person still choosing.\n- **`statusAt:` and `attempts:` are bookkeeping, not content — never hand-edit them.** A loop driving this base writes both. `statusAt:` is stamped by whatever moved the status, and is what makes \"how long has this been building?\" answerable; the file's mtime is not the transition time, because a note is edited after dispatch for all sorts of reasons. `attempts:` is `seat@hash` entries recording which seat has already tried to move this note forward, against the content it was trying to move — it is the loop's only brake, and clearing it by hand hands back a budget that exists to stop a note nothing can satisfy being rewritten forever. Rewriting the note's real content refunds that budget on its own, which is the point: an answer that changes the note is what unsticks it.\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### What to do next\n\n```bash\npikku knowledge next # the one thing to do next, derived from what is on disk\n```\n\n`next` is a pure read: it looks at the notes and answers with exactly one action —\n`repair-note`, `write-plan`, `ask-user`, `dispatch`, `hold`, or `idle`. Nothing has to\nbe armed by whoever noticed a transition, so calling it twice is free and a state\nnobody anticipated is a missing answer rather than a run that quietly stops.\n\nTwo things about the output matter if you are driving it:\n\n- **`reason` is machine wording.** It names the note, the frontmatter key and what the\n gate wanted. Never repeat it to a person — they have not seen a note and it will read\n as gibberish about files.\n- **`ask-user` carries a `question` as well.** That IS the version for a person: a\n `header`, the question in the language of their app, and `options` when the answer\n comes from a closed vocabulary (which `status:` it is, which `surface:` it is).\n `options` is empty when the answer is free text, and an empty list means offer free\n text — never invent choices to fill it.\n\n`hold` means a profile's own gate is holding the milestone and no seat this loop knows\nabout can clear it. It names the hold and the notes it is about; what to do then\nbelongs to that profile, not here.\n\n### The milestone plan\n\nA milestone note says what the app must DO. Its **plan** — JSON beside the note, not prose — says what has to exist for it, and is what a finished build is measured against:\n\n```bash\npikku knowledge plan schema # the format, in full\npikku knowledge plan set <milestone> <file> # validate and write it\npikku knowledge plan show <milestone> --for-build # the ordered work a build follows\npikku knowledge plan progress <milestone> # what it still owes, read from .pikku/\npikku knowledge plan defer <milestone> <item> -r \"<why>\"\n```\n\n`progress` reconciles the plan against pikku's generated meta — set membership, never anyone's status — and exits non-zero while the first pass is short, or while anything already built contradicts the plan. Unbuilt work in a later pass is reported, not blocked; a function that shipped wide open against a planned permission rule blocks from any pass, because that is a hole rather than a backlog. Writing a plan is its own seat: read `pikku-architect`. Building against one is `pikku-build`.\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), HOW MANY ROUND TRIPS a function body costs and how to\n collapse sequential awaits into one statement, 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, a function body\n awaits more than one query, or the user asks about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB or Redis-backed\n services (use pikku-service-backends).\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\n .selectFrom('item')\n .select(['id', 'name', 'quantity'])\n .where('warehouseId', '=', warehouseId)\n .orderBy('name')\n .limit(50)\n .execute()\n\n// JOINS + aliased selects (qualify columns once a join exists)\nawait kysely\n .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\n .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\n .insertInto('item')\n .values({ name: input.name, warehouseId })\n .returning(['id', 'name'])\n .executeTakeFirstOrThrow()\n\n// UPDATE + RETURNING, DELETE\nawait kysely\n .updateTable('item')\n .set({ quantity: input.quantity })\n .where('id', '=', input.id)\n .returning(['id', 'quantity'])\n .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\n .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\n .selectFrom('warehouse')\n .select((eb) => [\n 'warehouse.id',\n 'warehouse.name',\n jsonArrayFrom(\n eb\n .selectFrom('bin')\n .select(['bin.id', 'bin.code'])\n .whereRef('bin.warehouseId', '=', 'warehouse.id')\n ).as('bins'),\n ])\n .execute()\n\n// TRANSACTION — multi-write atomicity. Use trx (not kysely) inside.\nawait kysely.transaction().execute(async (trx) => {\n await trx\n .updateTable('stock')\n .set({ quantity: 0 })\n .where('itemId', '=', id)\n .execute()\n await trx\n .insertInto('stockMove')\n .values({ itemId: id, delta: -qty })\n .execute()\n})\n```\n\n## One statement, not five\n\n**Count the `await`s in the function body before you finish it.** In a deployed\nstage the database is not in the process — every terminal (`.execute()`,\n`.executeTakeFirst()`) is a network hop, and five in a row is five latencies the\ncaller waits through in series. This is the single most common thing wrong with a\ngenerated function body, and it never shows up locally against a socket on the\nsame machine.\n\n**Sequential is only correct when the second query needs the first one's\nVALUES.** Everything else is one of these four:\n\n**1. Independent reads → `Promise.all`.** Nothing about the SQL changes; they\njust stop queuing behind each other.\n\n```typescript\n// Three hops, in series\nconst item = await kysely.selectFrom('item').where('id','=',id).selectAll().executeTakeFirst()\nconst bins = await kysely.selectFrom('bin').where('warehouseId','=',wid).selectAll().execute()\nconst moves = await kysely.selectFrom('stockMove').where('itemId','=',id).selectAll().execute()\n\n// One hop's worth of latency\nconst [item, bins, moves] = await Promise.all([\n kysely.selectFrom('item').where('id','=',id).selectAll().executeTakeFirst(),\n kysely.selectFrom('bin').where('warehouseId','=',wid).selectAll().execute(),\n kysely.selectFrom('stockMove').where('itemId','=',id).selectAll().execute(),\n])\n```\n\n**2. Parent, then its children → `jsonArrayFrom` / `jsonObjectFrom`.** A loop\ncontaining an `await` is an N+1: one query per row, so the cost is the size of the\nresult set rather than the size of the code. **Never `await` inside a `for`/`map`\nover rows you just fetched.** The relation helpers in the cookbook above collapse\nit into one statement that returns the nested shape your output schema already\nwants.\n\n```typescript\n// N+1 — one extra hop per warehouse\nconst warehouses = await kysely.selectFrom('warehouse').selectAll().execute()\nfor (const w of warehouses) {\n w.bins = await kysely.selectFrom('bin').where('warehouseId','=',w.id).selectAll().execute()\n}\n// One hop — see NESTED DATA above\n```\n\nIf the shape genuinely cannot be nested, fetch the children in **one** query with\n`where('warehouseId', 'in', warehouses.map((w) => w.id))` and group them in JS.\nTwo hops beats N.\n\n**3. Read, decide, write → one write that returns.** A `select` to check\nexistence followed by an `insert` is both two hops and a race — another request\ncan insert between them. `returning()` and `onConflict` do it in one statement,\nand what comes back tells you which branch happened.\n\n```typescript\n// Two hops and a race\nconst existing = await kysely.selectFrom('item').where('sku','=',sku).select('id').executeTakeFirst()\nif (existing) throw new ConflictError()\nawait kysely.insertInto('item').values({ sku, name }).execute()\n\n// One hop, and the database arbitrates\nconst created = await kysely\n .insertInto('item')\n .values({ sku, name })\n .onConflict((oc) => oc.column('sku').doNothing())\n .returning(['id', 'sku'])\n .executeTakeFirst()\nif (!created) throw new ConflictError()\n```\n\nThe same applies to fetch-then-update: `updateTable(...).where(...).returning(...)`\nin one call, and `undefined` back means the row was not there — that is your\n`NotFoundError`, not a reason for a preceding `select`. Many single-row inserts\nare one `.values([...])` with an array.\n\n**4. A read that only feeds the next query's `where` → a subquery or a CTE.**\nIf the first result never reaches the response and never reaches JS, it should\nnever have crossed the wire.\n\n```typescript\n// Two hops — the ids are only ever used as a filter\nconst ids = await kysely.selectFrom('bin').where('warehouseId','=',wid).select('id').execute()\nconst stock = await kysely.selectFrom('stock').where('binId','in', ids.map((b) => b.id)).selectAll().execute()\n\n// One hop\nconst stock = await kysely\n .selectFrom('stock')\n .where('binId', 'in', (eb) =>\n eb.selectFrom('bin').select('bin.id').where('bin.warehouseId', '=', wid)\n )\n .selectAll()\n .execute()\n```\n\n`.with('name', (db) => ...)` builds a CTE when the same intermediate is needed\ntwice inside one statement. A total alongside a page is a window function —\n`eb.fn.countAll<number>().over().as('total')` — not a second `count` query.\n\n**A transaction does NOT reduce round trips.** It adds `BEGIN` and `COMMIT` around\nwhatever is inside it. Reach for it when several writes must land together or not\nat all; never as a way to make sequential queries cheaper, and never wrapped round\nreads that only needed `Promise.all`.\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| `*AgentStorageService` | `AgentStorageService, AgentRunStateService` | 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` | Outgoing webhook deliveries + attempt history (see `pikku-webhook`) |\n| `KyselyCredentialService` | Encrypted third-party credentials |\n| `KyselyAgentRunStateService` | AI run state (also implemented by AIStorage) |\n| `KyselyWorkflowMirror` | Mirrors workflow runs into queryable tables |\n| `KyselyAuditService` | Durable audit sink (see `pikku-services`) |\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-services` 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-list-query/SKILL.md": "---\nname: pikku-list-query\ndescription: >-\n Use when building a paginated/infinite-scroll list — any RPC that returns rows a user scrolls through (tables, card grids, search results). Covers pikkuListFunc, the ListInput/ListOutput cursor contract, and the generated usePikkuInfiniteQuery hook.\n TRIGGER when: user asks for infinite scroll, \"load more\", a paginated table/list/grid, or a list that could grow beyond a single page.\n DO NOT TRIGGER when: the list is small and fixed (e.g. a settings page with 5 items) — a plain pikkuFunc + usePikkuQuery returning a full array is simpler and correct there.\ninstallGroups: [core, client]\n---\n\n# Pikku List Queries\n\n## Agent Operating Procedure\n\n1. Capture baseline. Run `pikku all` BEFORE writing code; only NEW errors are yours to fix.\n2. Write the backend function with `pikkuListFunc` (below) — never a bespoke `{items: [...]}` shape once the list can page.\n3. Run `pikku all` to regenerate `usePikkuInfiniteQuery` for the new function.\n4. Wire the frontend with `usePikkuInfiniteQuery`, not a hand-rolled `useState` page counter and not a raw `useInfiniteQuery` — the generated hook already resolves cursor plumbing from your function's types.\n5. Validate with `pikku all`.\n\n## The `pikkuListFunc` contract\n\nA list function is a normal `pikkuFunc`/`pikkuSessionlessFunc` whose input/output conform to two shared shapes from `@pikku/core`:\n\n```typescript\ninterface ListInput<F extends Record<string, unknown> = {}, S extends string = never> {\n cursor?: string // opaque — echo back whatever you returned as nextCursor\n limit?: number // page size; server may cap it\n sort?: Array<{ column: S; direction: 'asc' | 'desc' }>\n filter?: Filter<F> // structured AND/OR tree, Prisma-style leaf operators\n search?: string // free-text search across server-chosen fields\n}\n\ninterface ListOutput<Row> {\n rows: Row[]\n nextCursor: string | null // null = no more pages\n totalCount?: number // optional — skip when expensive to compute\n}\n```\n\nAdopting this shape is what makes the function eligible for the generated `usePikkuInfiniteQuery` hook — the react-query codegen structurally detects any RPC whose output includes `nextCursor` and generates an infinite-query hook for it automatically. No manual wiring, no opt-in flag.\n\n```typescript\nimport { pikkuListFunc } from '#pikku/function'\n\ninterface Item {\n id: string\n label: string\n}\n\nexport const listItems = pikkuListFunc<{ status?: string }, Item>({\n expose: true,\n auth: true,\n readonly: true,\n description: 'List items for the signed-in user, paginated.',\n // `input` is inferred as ListInput<{ status?: string }> from the generics above —\n // never re-annotate it inline.\n func: async ({ kysely }, input, { session }) => {\n // `limit` is caller-supplied on an exposed RPC, so it is CAPPED, not trusted —\n // `ListInput` says \"server may cap\" and this is where that happens.\n const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20) || 20, 1), 100)\n const parsed = input.cursor ? Number(input.cursor) : 0\n const offset = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0\n\n let query = kysely.selectFrom('item').where('userId', '=', session!.userId)\n const status = leafEquals(input.filter, 'status')\n if (status !== undefined) {\n query = query.where('status', '=', status)\n }\n\n const rows = await query.orderBy('createdAt', 'desc').offset(offset).limit(limit).execute()\n const nextOffset = offset + rows.length\n const totalCount = await query\n .select((eb) => eb.fn.countAll<number>().as('count'))\n .executeTakeFirstOrThrow()\n\n return {\n rows: rows.map((r) => ({ id: r.id, label: r.label })),\n nextCursor: nextOffset < totalCount.count ? String(nextOffset) : null,\n totalCount: totalCount.count,\n }\n },\n})\n```\n\nCursor doesn't have to be a numeric offset — any opaque string works (a keyset value, an encoded timestamp, etc.), as long as you can turn it back into a query position on the next call.\n\n## Frontend: `usePikkuInfiniteQuery`\n\nGenerated automatically alongside `usePikkuQuery`/`usePikkuMutation` once `reactQueryFile` is configured (see the react-query wiring docs) — no separate setup for list functions specifically.\n\n```tsx\nimport { usePikkuInfiniteQuery } from '.pikku/pikku-react-query.gen'\n\nfunction ItemList() {\n const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = usePikkuInfiniteQuery(\n 'listItems',\n { limit: 20 }, // never pass cursor here — the hook manages it\n )\n\n const rows = data?.pages.flatMap((page) => page.rows) ?? []\n\n return (\n <>\n {rows.map((row) => (\n <div key={row.id}>{row.label}</div>\n ))}\n {hasNextPage && (\n <button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>\n Load more\n </button>\n )}\n </>\n )\n}\n```\n\nFor scroll-triggered loading (rather than a button), pair it with an `IntersectionObserver` sentinel at the end of the list that calls `fetchNextPage()` when it enters the viewport and `hasNextPage` is true — don't poll on a scroll event handler.\n\n## Common mistakes\n\n- **Bespoke output shape** (`{items, total}` with no `nextCursor`) — compiles, but disqualifies the function from `usePikkuInfiniteQuery`; you're left hand-rolling pagination state. Use `ListOutput<Row>`'s field names (`rows`, `nextCursor`) even if you don't need `filter`/`sort`/`search` yet — they're optional.\n- **Fixed large `limit` instead of real pagination** (e.g. `{ limit: 500 }` fetched once) — works until the collection outgrows the cap, then silently truncates. If a list can grow unbounded, page it from the start.\n- **Passing `cursor` manually into `usePikkuInfiniteQuery`'s input argument** — the hook injects it into each page request itself; the input you pass is the _base_ filter/limit shared by every page.\n\n## `filter` is a TREE, not a bag of fields\n\n`Filter<F>` is recursive: an **array** is an AND of its children, a **multi-key object** is\nan OR keyed by labels that mean nothing at evaluation time, and only a **single-key object**\nis a leaf. A leaf's value is either the value itself or an operator object\n(`{ contains, in, gt, gte, lt, lte, not, startsWith, … }`).\n\nSo `'status' in input.filter` answers `false` for `[{ status: 'open' }, { userId: 'u1' }]`\nand for `{ status: { in: ['open', 'held'] } }` — the first because the filter is an array,\nthe second because the value is an operator object rather than the string the code then\ncompares. Both cases **silently return unfiltered rows**, which on a list endpoint means\nhanding back records the caller asked to exclude. Pikku ships no filter-to-SQL helper: the\nbackend decides what it accepts, and it has to say so.\n\nRead exactly the shape you support, and refuse the rest rather than ignoring it:\n\n```typescript\nimport type { Filter } from '@pikku/core/function'\n\n/** The one shape this endpoint accepts: a single-key leaf with a plain value. */\nfunction leafEquals<F extends Record<string, unknown>, K extends keyof F & string>(\n filter: Filter<F> | undefined,\n field: K,\n): F[K] | undefined {\n if (!filter || Array.isArray(filter)) return undefined\n const keys = Object.keys(filter)\n if (keys.length !== 1 || keys[0] !== field) return undefined\n const value = (filter as Record<string, unknown>)[field]\n if (value !== null && typeof value === 'object') {\n throw new Error(`filter.${field} takes a value, not an operator object`)\n }\n return value as F[K]\n}\n```\n\nSupporting AND/OR or operators means walking the tree properly — recurse into the array and\nthe multi-key object, and map each leaf operator to its Kysely equivalent. Do that when the\nUI needs it; until then, throwing on the shapes you do not handle is what stops a filter\nfrom being quietly dropped.\n", "pikku-meta/references/audit.md": "# 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## The `pikku update` command\n\nNarrower than `audit --outdated`, and the only one that writes: it moves the\n**@pikku/\\* set** forward and reports the peers those versions need. Use `audit`\nto learn a dependency is vulnerable; use `update` to move Pikku itself.\n\n- `pikku update` — reports only. Nothing is written without `--update`.\n- `pikku update --update` — writes the new ranges into every covered\n package.json, then runs an install. `--no-install` writes and stops.\n- `pikku update --update-peers` — implies `--update` and additionally writes the\n ranges unsatisfied peers require, **for peers the project already declares**.\n A peer it does not declare is reported and never added — adding a dependency\n is not an update. Separate from `--update` because a peer bump can cross a\n **major** of a third-party package (`ai` 5 → 6), which is not a call to make\n on the user's behalf.\n- `--tag <dist-tag>` (default `latest`) reads each package's own dist-tag, so\n `--tag next` moves the whole set onto prereleases. `--registry <url>` defaults\n to `npm_config_registry`.\n- Coverage is the nearest package.json walking up from the project root, plus\n every workspace it declares — a monorepo updates in one pass. All four\n dependency fields are read, `peerDependencies` included, so an addon's own\n declared peer range moves with it.\n\nStatuses, per dependency: `outdated` (the range floor is behind latest — this is\nwhat `--update` writes), `stale-install` (the range already admits latest but\nnode_modules is behind — an install fixes it, no edit needed), `linked` (a\n`workspace:`/`file:`/`link:`/`portal:` range — a deliberate local checkout,\ncounted but never listed), `manual` (a registry range we refuse to substitute\ninto: a union, an x-range, a `*`), `unresolved` (the registry had no such tag —\nthis must **never** read as \"current\", the same rule as a failed audit).\n\nPeers are read off the version the run **lands on**, not the one installed —\nthe point is what the target needs. An @pikku peer the same run already brings\nforward is not reported, and an unsatisfied _optional_ peer the project never\ndeclared is skipped.\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-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-meta/references/meta.md": "# Pikku Project Metadata\n\n`pikku meta` is the machine-readable view of the project and the write path to it.\n`pikku info` is the same ground as human-readable tables. Prefer `meta` when you are\ngoing to act on the output; prefer `info` when a person is going to read it.\n\n\n## Reading\n\n| Command | What it answers |\n| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| `pikku meta context` | Everything a planner needs in one call — functions, wires, middleware, permissions, workflows, capabilities, layout. Start here. |\n| `pikku meta functions get <id>` | One function's input/output schema names, source file, tags, expose/readonly |\n| `pikku meta schemas get <name>` | One generated JSON schema |\n| `pikku meta workflows get <id>` | One workflow's steps |\n| `pikku meta permissions list` | What permissions exist and where they are defined |\n| `pikku meta middleware list` | What middleware exists |\n| `pikku meta wires list` | Wires by transport (http, channel, scheduler, queue, trigger) |\n| `pikku meta clients` | Exposed RPCs/workflows/channels with their type names — what a frontend can call |\n\n`list` is the default for each group, so `pikku meta functions` and `pikku meta functions list`\nare the same call.\n\nA function's input/output shape comes from here. Do not infer it by reading the\nfunction body, and do not cast a call site to make it compile — the schema is the type.\n\n## Changing\n\n`pikku meta apply` applies a batch of edits to your own source. Pass JSON as a file\nor on stdin:\n\n```bash\npikku meta apply ops.json\n```\n\n```json\n{\n \"operations\": [\n {\n \"kind\": \"functionConfig\",\n \"sourceFile\": \"src/functions/todos.functions.ts\",\n \"exportedName\": \"listTodos\",\n \"changes\": { \"title\": \"List Todos\", \"tags\": [\"todos\", \"read\"] }\n },\n\n {\n \"kind\": \"functionConfig\",\n \"sourceFile\": \"src/functions/todos.functions.ts\",\n \"exportedName\": \"listTodos\",\n \"changes\": {\n \"permissions\": {\n \"functionLevel\": {\n \"name\": \"isTodoOwner\",\n \"from\": \"../permissions.js\"\n }\n }\n }\n }\n ]\n}\n```\n\nThree kinds: `functionConfig`, `agentConfig`, `functionBody`. Every operation names\na `sourceFile` and the `exportedName` declared in it.\n\n`functionConfig` changes: `title`, `description`, `summary`, `tags`, `errors`,\n`expose`, `remote`, `mcp`, `readonly`, `approvalRequired`, `permissions`.\n`agentConfig` changes: `name`, `description`, `instructions`, `role`, `personality`,\n`goal`, `model`, `maxSteps`, `temperature`, `toolChoice`, `tools`, `tags`.\n\n`null` removes a property. Edits are spliced into the original text, so formatting,\ncomments and JSDoc survive.\n\n`permissions` and `tools` are written as identifiers rather than literals, so each\none carries the module it comes from (`{\"name\": \"isTodoOwner\", \"from\": \"../permissions.js\"}`)\nand the missing import is added for you — widening an existing import from that\nmodule rather than adding a second one.\n\n### Why batch\n\nThe whole batch either lands or it does not: every operation is resolved before\nanything is written, so a failure leaves every file untouched and names the\noperation that caused it. Batching is also what makes one codegen pass correct —\n**run `pikku all` once after the batch**, not once per property. The response tells\nyou whether it is needed:\n\n```json\n{\n \"schemaVersion\": \"meta-apply.v1\",\n \"applied\": 2,\n \"files\": [\"src/functions/todos.functions.ts\"],\n \"generatedMetaIsStale\": true\n}\n```\n\n## Human-readable tables (`pikku info`)\n\nFour subcommands only — `functions`, `tags`, `middleware`, `permissions`. Routes,\nchannels, schedulers and queues are not subcommands; they are the _transport_ column\nof `info functions --verbose`.\n\n```bash\nyarn pikku info functions --verbose --silent\nyarn pikku info tags --silent\nyarn pikku info middleware --verbose --silent\nyarn pikku info permissions --verbose --silent\n```\n\n`--silent` suppresses the banner and inspector diagnostics. It works, but it is not\ndeclared as an option, so every run also prints `Warning: Unknown option: --silent\n(ignored)` — the warning is wrong. Ignore that one line.\n\n`--limit N` caps rows (default 50); the footer says how many were withheld.\nOn `tags`, `--verbose` swaps counts for names; elsewhere it adds columns.\n", "pikku-meta/references/versioning.md": "# Pikku Function Versioning\n\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## The `pikku semver` command\n\n`versions check` and `semver` answer different questions and share no state.\n`check` is a within-repo gate — \"you changed a contract without bumping\n`version:`\". `semver` is a release question — \"what does this build owe the\nclients of the one already deployed?\" — and needs an **external baseline**,\nwhich is why the answer is always relative to an environment rather than to\nthe previous commit.\n\n```bash\nnpx pikku semver --against https://api.acme.com/surface.json # vs production\nnpx pikku semver --against ../other-app/.pikku # vs a checkout\nnpx pikku semver --emit --out surface.json # publish a baseline\nnpx pikku semver --against ... --fail-on major # CI gate\n```\n\n`--against` takes three things and tells them apart itself: a directory is read\nas a `.pikku` tree, an `http(s)` URL is fetched as a published snapshot, and any\nother file is read as a snapshot. `--emit` produces the snapshot; **use `--out`**\n— plain `--emit` writes to stdout _after_ the CLI banner, so a bare `> file.json`\ncaptures the banner too. Publish the snapshot from CI on deploy and it becomes\nthe baseline everyone else compares against.\n\nThe verdict, in order:\n\n- **major** — a function or client-facing wiring was removed, or a surviving\n one tightened its contract.\n- **minor** — anything was added, or a contract loosened compatibly.\n- **patch** — the surface did not move; the release is internal work.\n\nBelow the id level it reads the generated JSON Schemas, and **direction\ndecides**. An input is contravariant (the caller writes it) and an output is\ncovariant (the caller reads it), so the same edit is not the same event on both:\n\n| Change | On an input | On an output |\n| ------------------------------ | ------------------------------------ | ------------ |\n| field removed | breaking (when the schema is closed) | breaking |\n| required field added | breaking | compatible |\n| field became optional | compatible | breaking |\n| optional field became required | breaking | compatible |\n| enum value removed | breaking | compatible |\n| enum value added | compatible | breaking |\n| type changed | breaking | breaking |\n\nIt consumes `versions.pikku.json`: published versions are immutable, so a\nfunction id that left the source while the manifest still records it is a `@vN`\nbump, not a removal — that is what keeps a deliberate version bump at `minor`.\nWithout the manifest the same disappearance reads as `major`, which is the safe\nreading rather than a wrong one.\n\nTwo things it deliberately will not guess. A named schema whose body did not\ntravel with the baseline falls back to `contractHash` and, if that moved, is\nreported **breaking with the reason stated** — never quietly \"unchanged\". And at\nthe wiring level only `auth` going from absent/false to true is classified as\nbreaking; every other metadata change is reported as compatible, because there\nis no general way to tell a cosmetic wiring edit from a restricting one.\n\nOutput is `.pikku/changes.gen.json` (override with `--out`), so it rides the\nsame meta pipeline as `audit.json`:\n\n```json\n{\n \"schemaVersion\": 1,\n \"baseline\": \"https://api.acme.com/surface.json\",\n \"verdict\": \"major\",\n \"summary\": { \"breaking\": 1, \"added\": 1, \"removed\": 0, \"modified\": 1 },\n \"changes\": [\n {\n \"kind\": \"function\",\n \"id\": \"getUser\",\n \"status\": \"modified\",\n \"breaking\": true,\n \"reasons\": [\"input.tenant: required field added\"]\n }\n ]\n}\n```\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 # Refuse to ship a breaking change to production unintentionally.\n - run: npx pikku semver --against https://api.acme.com/surface.json --fail-on major\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-meta/SKILL.md": "---\nname: pikku-meta\ndescription: >-\n Use to inspect or evolve a project you did not just write — `pikku meta` and `pikku info` for\n what the project declares (functions, schemas, wires, workflows, middleware, permissions) and\n `pikku meta apply` to change it, `pikku versions` / `pikku semver` for contract hashes,\n breaking-change detection and the semver a release should get, and `pikku audit` / `pikku\n update` for dependency advisories and moving Pikku forward. TRIGGER when: user asks what\n functions or routes exist, wants a function's input/output shape, wants to retag a function or\n set config on a declaration, asks about API versioning, breaking changes, what semver a release\n deserves, dependency vulnerabilities, the console Security screen, or upgrading Pikku. DO NOT\n TRIGGER when: user is writing a new function or wiring (use the wiring skill) or asking about\n Pikku concepts (use pikku-concepts).\ninstallGroups: [core]\nallowed-tools: Bash(yarn pikku meta *), Bash(yarn pikku info *)\nargument-hint: '[context|functions|schemas|workflows|middleware|permissions|wires|apply|versions|semver|audit|update]'\n---\n\n# Pikku Project Metadata\n\nThe project already knows what it declares. Ask it rather than grepping for it,\nand change it through the write path rather than by hand.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Asking what exists, or setting config on a declaration | `references/meta.md` |\n| Versioning a contract, or deciding a release's semver | `references/versioning.md` |\n| Chasing a dependency advisory, or upgrading Pikku | `references/audit.md` |\n\n## Start with `pikku meta context`\n\nIt answers in one call what a planner needs — functions, wires, middleware,\npermissions, workflows, capabilities, layout. Reach for `pikku meta` when you\nare going to act on the output and `pikku info` when a person will read it;\nthey are the same ground in two shapes.\n\n## Direction decides whether a change is breaking\n\nAn input is contravariant (the caller writes it) and an output is covariant (the\ncaller reads it), so the same edit is not the same event on both. Adding a\nrequired field breaks an input and is compatible on an output; making a field\noptional is the reverse. `pikku semver` reads the generated JSON Schemas with\nthat asymmetry built in, so let it decide rather than eyeballing a diff.\n\n## What NOT to do\n\n- **Do not infer a function's input or output by reading its body**, and do not\n cast a call site to make it compile. The schema is the type; `pikku meta\n functions get <id>` has it.\n- **Do not expect an unversioned function to be promoted for you.** Without an\n explicit `version: 2` it is version 1 of its contract, collides with the\n pinned `@v1`, and `pikku versions check` reports the published contract as\n modified.\n- **Do not reach for `override` by default.** The contract key already drops a\n matching `V<n>` suffix from the export name, so `getBookV1` keys under\n `getBook`. `override` is for an export that cannot follow that convention.\n- **Do not shell out to the package manager from a function.** The audit is a\n generated artifact — read `.pikku/audit.json` through\n `metaService.readFile('audit.json')`.\n- **Do not redeclare the audit report's shape.** `SecurityAuditReport` and its\n companions come from `@pikku/core`; the CLI writes it, the addon reads it, the\n UI renders it.\n- **Do not treat a failed audit run as a clean one.** `bun audit` exits non-zero\n when it *finds* advisories and still writes its payload, so non-zero with\n output is data; non-zero with no output throws on purpose.\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(\n 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\n .selectFrom('apiKey')\n .select('userId')\n .where('key', '=', header)\n .executeTakeFirst()\n if (row) setSession?.({ userId: row.userId })\n\n return next()\n }\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, sessions or auth strategies like authBearer/authCookie (use\n pikku-auth), 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/middleware'\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\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/error`.\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('*', [\n cors({ origin: 'https://app.example.com', credentials: true }),\n])\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/middleware'\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## ⛔ MACHINE AUTH: THE TOKEN BECOMES A SESSION. ⛔\n\n**A caller that has an identity — a sandbox, a deployed stage, a pool host, a device — is authenticated ONCE, in middleware, which calls `setSession`. The function is then an ordinary `pikkuFunc` gated with `scopes`. It reads `session`. It NEVER re-derives who the caller is.**\n\nEither a function is sessionless (genuinely public) or it has a session. Anything in between — a token verified inside `func`, a token verified in a `permissions` check that returns `true`, an identity passed in the input schema, the same resolver memoised per request so N functions can each call it — is the anti-pattern this section exists to kill.\n\n```typescript\n// middleware.ts — resolve the bearer ONCE, for every route\nconst sandboxBearerAuth = pikkuMiddleware<SingletonServices>(\n async ({ kysely, auth }, { http, getSession, setSession }, next) => {\n if (await getSession?.()) return next()\n const header = http?.request?.header?.('authorization')\n if (!header?.startsWith('Bearer ')) return next()\n const sandbox = await resolveSandboxSession(kysely, auth, header.slice(7).trim())\n if (sandbox) {\n setSession?.({ userId: sandbox.createdByUserId ?? sandbox.sandboxInstanceId,\n orgId: sandbox.organizationId, scopes: ['machine:sandbox'], sandbox } as UserSession)\n }\n return next()\n },\n)\n\naddHTTPMiddleware('*', [cors(...), betterAuthSession(), apiBearerAuth, sandboxBearerAuth as any])\n```\n\n```typescript\n// functions/report-something.function.ts\nexport const reportSomething = pikkuFunc({\n expose: true,\n scopes: ['machine:sandbox'], // ← the gate. Enforced by the runner, seen by the inspector.\n input: ReportSomethingInput,\n output: ReportSomethingOutput,\n func: async ({ kysely }, input, { session }) => {\n const sandbox = sandboxOf(session) // ← narrowing only, no verification\n ...\n },\n})\n```\n\nAn unresolved token leaves the session unset and the function throws `MissingSessionError` — 401, for free. Declare the scope tree once with `defineScope` (see `pikku-auth`).\n\n### It MUST be `addHTTPMiddleware`, never `addTagMiddleware`\n\n**A session set in tag middleware is invisible to the function when the call arrives over `POST /rpc/:rpcName`.** Tag middleware runs inside `runPikkuFunc`, and the RPC dispatch calls it without a `sessionService`, so `invocationWire.session` is never re-read after your `setSession` — the function sees the session the OUTER wire had, which is none. `addHTTPMiddleware('*')` runs on the `/rpc` route itself, before its handler, and that session is the one the dispatched function inherits. Tag middleware is still right for a gate that only says yes/no.\n\n### A cron is a machine identity too — set it in the task's own middleware\n\nA scheduled task has no caller and no header, but it is still a machine principal, and without a session it cannot invoke a gated RPC or be attributed in anything it writes. Give it one the same way, in the task's own `middleware`:\n\n```typescript\nconst cronSession = pikkuMiddleware(async (_services, { scheduledTask, setSession }, next) => {\n setSession?.({ userId: `cron:${scheduledTask?.name}`, scopes: ['machine:cron'] } as UserSession)\n return next()\n})\n\nwireScheduler({\n name: 'tickVirtualUserSchedules',\n schedule: '*/15 * * * *',\n middleware: [cronSession],\n func: tickVirtualUserSchedules,\n})\n```\n\nOne `const`, not a `machineSession(name)` factory: the inspector rejects a bare `pikkuMiddleware()` that is not assigned to a variable or object property, and the task name is on the wire anyway. Parameterised middleware goes through `pikkuMiddlewareFactory`.\n\nThe task can then be a thin `rpc.invoke('someGatedRpc')` against the same entry point a person calls, instead of factoring the logic into a `lib/` helper purely to route around the missing identity.\n\nUnlike tag middleware over `/rpc`, this works: `runScheduledTask` builds its wire with a `sessionService`, so the session set here is the one the function is frozen with. And unlike a person, a cron is **not** a user row — inventing a seeded account for it buys a phantom member in every list, seat count and bill, and a per-org membership that a cross-org sweep has to ignore anyway. Platform-wide authority is a scope, not a membership.\n\n### The one sessionless exception: bootstrap\n\nAn endpoint that runs BEFORE the caller has an identity — registering a new host with a shared bootstrap key, a login, a device-code request — has no session to set. That one stays `pikkuSessionlessFunc` and declares its gate in `permissions` (see `pikku-auth`).\n\n## Service-to-Service Bearer Auth (gate-only pattern)\n\nUse this when the callee needs to know only THAT the caller is trusted, not WHICH caller it is. If it needs to know which, use the session pattern above.\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-auth`), 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) => {\n _token = t\n}\nexport const getToken = () => _token\n```\n\n```typescript\n// wirings/http.wiring.ts\nimport { timingSafeEqual } from 'node:crypto'\nimport { addTagMiddleware, pikkuMiddleware } from '#pikku/middleware'\nimport { UnauthorizedError } from '#pikku/error'\nimport { getToken } from '../lib/host-token.js'\n\nconst bearerAuth = pikkuMiddleware(async (_services, { http }, next) => {\n const authHeader =\n http?.request?.header?.('authorization') ||\n 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-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\": {\n \"gmailOAuth2\": { \"id\": \"...\", \"name\": \"Personal Gmail\" },\n },\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()`:\n\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\n```ts\nimport { messageSend } from '@pikku/addon-email-gmail'\nexport const agentGmailtool__sendAMessageInGmail = messageSend\n```\n\nDefault is delete + retarget; wrappers add maintenance burden.\n\n**B) `isAgentTool: false`** — the stub is a graph node:\n\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\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(\n 'Stub: ported from n8n Code node \"Custom Code\" — implement me'\n )\n },\n})\n```\n\nAfter:\n\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\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\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\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-permissions/SKILL.md": "---\nname: pikku-permissions\ndescription: >-\n Use when deciding WHO may call a function — resource ownership, role gates, admin-only actions, or any \"only their own rows\" rule. Covers the `permissions` field, `pikkuPermission`, `pikkuAuth`, scopes, and where ownership belongs versus where it does not.\n TRIGGER when: writing or reviewing any function that touches a row a user owns, gating an action on a role, building the permissions half of a contract in build PHASE 2, or about to write an `if` in a function body that decides whether the caller is allowed.\n DO NOT TRIGGER when: the question is how to sign someone in or seed a persona (that is pikku-auth), or how to shape a paginated list (that is pikku-list-query).\ninstallGroups: [core]\n---\n\n# Pikku Permissions\n\n## The rule\n\n**Authorization goes in the `permissions` field. Never in the `func` body.**\n\n`permissions` runs before `func`, and it is DECLARED — `pikku meta` and the auditor can\nsee it. An `if` in the body is the same check, invisible: nothing can tell you which\nfunctions are gated or how, and the next person to add a caller gets no warning.\n\n```typescript\n// RIGHT\nexport const deleteBook = pikkuFunc({\n permissions: { owner: isBookOwner },\n func: async ({ kysely }, { bookId }) => {\n await kysely.deleteFrom('book').where('bookId', '=', bookId).execute()\n },\n})\n\n// WRONG — the gate is buried in the body\nexport const deleteBook = pikkuFunc({\n func: async ({ kysely }, { bookId }, { session }) => {\n const book = await kysely.selectFrom('book')...executeTakeFirst()\n if (book?.ownerId !== session.userId) throw new UnauthorizedError()\n await kysely.deleteFrom('book').where('bookId', '=', bookId).execute()\n },\n})\n```\n\n## `auth: true` IS NOT OWNERSHIP — this is the one people get wrong\n\n`auth: true` means \"somebody is signed in\". It does NOT mean \"this row is theirs\". A CRUD\nfunction set to `auth: true` and nothing else lets ANY signed-in user delete ANY other\nuser's row by passing its id. Every function that takes a row id needs BOTH: `auth: true`\nfor the session, and a `permissions` entry for the ownership.\n\nEqually: do NOT write an `isSignedIn` permission that returns `!!session`. That re-checks\nauthentication, which `auth: true` already did. A permission answers *may this user do\nthis* — role, ownership, tier — never *is there a session*.\n\n## Single row vs list — where ownership actually goes\n\nThis is the distinction to get right, and both halves are correct code:\n\n- **A function taking a row id** (`get`, `update`, `delete`) — ownership is a\n PERMISSION. Load the row, compare the owner to the session. It is a yes/no question\n about one row, which is exactly what a permission is.\n- **A function returning many rows** (`list`, `search`, any stats query) — ownership is\n a `WHERE` clause in the query, because \"only their rows\" is a filter, not a yes/no.\n There is no permission to write here; scoping the query IS the enforcement.\n\nA list that fetches everything and then filters in JS is a bug, not a permission.\n\n## Writing the checkers\n\nPut them in `src/permissions/`, one file per entity, and reuse one checker across every function on that\nentity rather than writing a near-copy per function.\n\n```typescript\n// src/permissions/book.ts\nimport { pikkuPermission, pikkuAuth } from '#pikku/auth'\n\n// Data-aware: gets the input, so it can load the row the caller named.\nexport const isBookOwner = pikkuPermission(\n async ({ kysely }, { bookId }, { session }) => {\n const book = await kysely\n .selectFrom('book')\n .select('ownerId')\n .where('bookId', '=', bookId)\n .executeTakeFirst()\n return book?.ownerId === session?.userId\n },\n)\n\n// Session-only: no input needed. Use for role and flag gates.\nexport const isAdmin = pikkuAuth(async (_services, session) => session?.role === 'admin')\n```\n\n## OR and AND\n\n```typescript\npermissions: {\n owner: isBookOwner, // OR — an owner may\n admin: isAdmin, // OR — an admin may\n editor: [isAdmin, isBookOwner] // AND — both, inside one group\n}\n```\n\nGroups are OR'd; entries inside a group array are AND'd.\n\n## Roles\n\nIf the app has roles, the role lives on the session (see pikku-auth / `mapSession`) and\nevery mutating or admin-only function names it in `permissions`. Gate the FUNCTION — hiding\nan admin button in the UI is UX, never enforcement, and a member who guesses the RPC name\ngets straight through if the function itself is open.\n\n## Scopes\n\n`scopes: ['admin:invoices:void']` is an AND gate checked BEFORE permissions and before\ninput validation. Declare the tree once with `defineScope`; a function naming an\nundeclared scope fails codegen rather than gating on nothing. A grant satisfies a scope if\nit is that scope, an ancestor, or a wildcard — a session holding `admin` satisfies\n`admin:invoices:void`. Most apps need roles, not scopes; reach for these only when the\nplan asked for granular grants.\n\n## The one sanctioned exception\n\n`permissionsInBody: true` — for a check that genuinely cannot be a permission because the\nidentity arrives in the payload and there is no session: a webhook signature, a signed\ntoken, an invite code. It is purely declarative and enforces nothing; its job is to tell\nthe auditor the openness is deliberate. Anything expressible as a permission must be one.\n\n## After changes\n\n`pikku all` — regenerates and typechecks the checkers. A permission whose signature is\nwrong fails here, not at runtime.\n", "pikku-react/references/client.md": "# Pikku React\n\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-wiring**), 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-wiring**.\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 | `references/workflows.md` |\n| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-wiring**) |\n\nThe first three live in your generated `api.gen.ts` (see the\n`references/react-query.md`). This reference 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\n`transformDate: true` revives **fully-zoned ISO-8601 instants** — `2026-03-14T08:12:00Z`,\n`2026-03-14T08:12:00.000+01:00` — into `Date` objects. Nothing else is touched: a bare\n`2026-03-14`, a zoneless `2026-03-14T08:12:00`, and a shaped-but-impossible\n`2026-02-31T00:00:00Z` all stay the strings the server sent, because each names a reading\nrather than a moment and `new Date` would guess a different instant per machine.\n\nSo the field's runtime type follows the VALUE, not the schema — one `z.string()` column can\narrive as a `Date` from one row and a string from the next. Two consequences, both of which\ntypecheck:\n\n- **A string method on a revived field throws at runtime.** `row.createdAt.split('T')[0]` —\n there is no string to slice.\n- **A raw `Date` in JSX crashes the route.** `<span>{row.createdAt}</span>` throws\n `Objects are not valid as a React child (found: [object Date])` and the page falls into its\n error boundary — a white screen, with nothing catching it first.\n\nFormat before rendering, with whatever date library the project already uses, and let it take\neither type. Coercing instead (`` `${d}` ``, `String(d)`) does not crash but prints\n`Mon Jun 15 2026 02:00:00 GMT+0200`, which is a different bug.\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### Dev actor sign-in (`useDevActors`)\n\nThe dev-only \"Sign in as …\" control: one click signs in as a declared scenario\npersona with no password, so the app can be reviewed as each kind of user.\n`pikku fabric validate` **requires** any frontend with a login screen to ship one\n(`app-missing-actor-quick-login-<app>`) — without it a reviewer is locked out of\ntheir own sandbox.\n\n```tsx\nimport { useDevActors } from '@pikku/react'\n\nconst { actors, signInAs, pendingEmail, isPending, error } = useDevActors({\n // Gate both reads on the bundler's dev flag so no credential can reach a\n // production bundle. The sandbox dev server bakes them from your personas.\n actors: import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined,\n secrets: import.meta.env.DEV\n ? import.meta.env.VITE_DEV_ACTOR_SECRETS\n : undefined,\n apiUrl: apiUrl(),\n onSignedIn: () => navigate({ to: '/' }),\n})\n```\n\n- **It is UI-free**, so render it however you like. For the default rendering use\n `<DevActorSwitcher />` from `@pikku/mantine/dev` — a separate entry point from\n `@pikku/mantine/core`, whose contract is \"drop-in alias for `@mantine/core`\"\n and so must not export components Mantine has no counterpart for.\n- **`secrets` is `{ address: credential }`, not one shared value** — a\n credential opens the one persona it was minted for (see\n **pikku-auth**). `actors` is empty unless the host supplied both a list\n and the credentials for it, and an actor with no credential is not offered, so\n a production build renders nothing without you testing for it.\n- **It takes `onSignedIn` rather than a router**, and takes the env values rather\n than reading them, because how env is spelled is a bundler fact\n (`import.meta.env.VITE_*` vs `process.env.NEXT_PUBLIC_*`).\n- The underlying `signInAsActor()` and `parseDevActors()` are exported too, for a\n non-React caller. The endpoint only accepts rows flagged `actor: true`, so it\n can never impersonate a real user — see **pikku-auth**.\n\nDo not hand-write the `devActors()` / `signInAsActor()` pair per app; that\ncopy-paste, including the `import.meta.env.DEV` gate, is exactly what this\nreplaced.\n\n### Linking from a Mantine element: `renderRoot`, not `component`\n\nHanding TanStack's `Link` to a Mantine element as `component={Link}` compiles,\nrenders, and navigates — and silently unties the type. Mantine's polymorphic\n`component` prop widens the router generic to `AnyRouter`, so `to` and\n`params` stop being checked against your actual routes. Renaming a route then\nbreaks the running app instead of the build, which is the one thing the typed\nrouter exists to prevent.\n\nWrap the typed `Link` once and reach it through `renderRoot`, which passes the\nprops through without re-typing the element:\n\n```tsx\n// components/links.tsx — one wrapper the whole app links through\nimport { Link } from '@tanstack/react-router'\n\nexport const AssessmentLink = (props: { assessmentId: string; children: React.ReactNode }) => (\n <Link to=\"/assessments/$assessmentId\" params={{ assessmentId: props.assessmentId }}>\n {props.children}\n </Link>\n)\n```\n\n```tsx\n<Button renderRoot={(p) => <AssessmentLink assessmentId={id} {...p} />}>\n Open\n</Button>\n```\n\nThe wrapper is where `to` and `params` are checked, and it is checked once.\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/references/react-query.md": "# Pikku React Query Hooks\n\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 `references/client.md`. 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 `references/workflows.md`.\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-react/references/workflows.md": "# Pikku Workflows — Client Hooks\n\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`references/client.md` and `references/react-query.md`).\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-react/SKILL.md": "---\nname: pikku-react\ndescription: >-\n Use when a React frontend talks to a Pikku backend — PikkuProvider and createPikku at the app\n root, the generated React Query hooks (usePikkuQuery, usePikkuMutation, usePikkuInfiniteQuery),\n direct usePikkuRPC / usePikkuFetch calls, realtime subscriptions, agent and workflow hooks, and\n the dev actor switcher. TRIGGER when: writing a React component that fetches or mutates backend\n data, wiring PikkuProvider, paginating, running or tracking a workflow from the client, or\n asking about useDevActors / VITE_DEV_ACTORS / quick login. DO NOT TRIGGER when: working on the\n backend (use pikku-wiring), defining the workflow itself (use pikku-workflow), or writing\n user-facing copy (use pikku-i18n).\ninstallGroups: [client]\n---\n\n# Pikku React\n\nThe hook names and their argument types come from your generated `api.gen.ts` —\nread it for what this app actually exposes. This skill is the part it cannot\ntell you: which hook a given need calls for, and where the generated client\nstops.\n\n## Pick the reference\n\n| You are… | Read |\n| --- | --- |\n| Wiring the app root, resolving the server URL, authenticating, or subscribing to realtime | `references/client.md` |\n| Fetching, mutating or paginating data | `references/react-query.md` |\n| Starting a workflow and showing its progress | `references/workflows.md` |\n\n## Reach for what\n\n| Need | Use |\n| --- | --- |\n| Render data, dedupe and cache | `usePikkuQuery` |\n| Trigger a write and wait for the result | `usePikkuMutation` |\n| Paginate | `usePikkuInfiniteQuery` |\n| One-off call from an event handler | `usePikkuRPC()` |\n| Hit a REST endpoint rather than an RPC | `usePikkuFetch()` |\n| Talk to one named AI agent | `usePikkuAgent(name)` → `.run` / `.stream` / `.approve` |\n| Run one named workflow | `usePikkuWorkflow(name)` → `.start` / `.run` / `.status` |\n| A workflow long enough to need progress UI | `references/workflows.md` |\n| Subscribe to events, SSE or a channel | `usePikkuRealtime()` |\n\nA workflow that finishes in a moment can be awaited; one that does not needs\nstart-plus-observe, or the component holds a pending state with nothing to show.\n\n## What NOT to do\n\n- **Do not write a client.** The generated one covers every exposed function\n with full types; a hand-rolled RPC client or a hand-written\n `useQuery({ queryKey, queryFn })` reimplements it worse.\n- **Do not instantiate `PikkuFetch`/`PikkuRPC` in a component.** `createPikku`\n runs once at the app root and the instance flows through context — and\n `usePikkuRPC()` outside `<PikkuProvider>` throws.\n- **Do not call the RPC client inside a `useEffect`.** The hooks handle\n deduplication, caching and unmounting; a manual effect handles none of them.\n- **Do not construct a hook name at runtime.** Hook names are the RPC names known\n at generation time, and a computed one is not type-checked.\n- **Do not poll a workflow with `setInterval`.** `useWorkflowStatus` with a\n `refetchInterval` callback dedupes across components and stops on a terminal\n state in one place.\n- **Do not reach for `as any` when a hook's types disagree with you.** The\n mismatch is the backend's input/output schema; fix it there.\n- **Do not hardcode a user-facing string.** Every display string goes through an\n i18n message — see `pikku-i18n`.\n", "pikku-realtime/SKILL.md": "---\nname: pikku-realtime\ndescription: >-\n Use when making ANY view live/realtime in a Pikku app — a board, shared list, dashboard, ticker, bidding room, live count — or when adding two-way chat/presence. Covers the DEFAULT event-hub SSE path and the two-way WebSocket channel.\n TRIGGER when: the user wants live updates, realtime, \"update without refresh\", a live board/feed/ticker/room, presence, or chat; or when data that MORE THAN ONE signed-in user can change should reflect others\n DO NOT TRIGGER when: a plain one-shot query/refetch is fine (data only one user changes, or a manual refresh is acceptable), or for background jobs (that is pikku-schedule/pikku-workflow).\ninstallGroups: [core, client]\n---\n\n# Pikku Realtime (SSE + WebSocket channels)\n\nThere is NOTHING to hand-roll and NOTHING to \"find\". The event-hub SSE transport\nis already wired into every app, and the two patterns below ARE the realtime\ntemplates. Start from them and rename — never grep the project for existing\n`sse`/`eventHub` code to copy, never write a custom `EventSource`, and never\nwrite a bespoke `sse: true` route for a plain live feed.\n\n## Pick the transport (almost always SSE)\n\n- **Server → client live updates → SSE via the event-hub.** This is the DEFAULT\n for making any view live: a board, list, dashboard, ticker, feed, or a \"room\"\n (a bidding room, sale room, live auction). The client only RECEIVES — the\n change itself happens through a NORMAL HTTP RPC (`placeBid`, `updateLot`, …)\n that publishes the new row.\n- **Client → server push mid-session → a WebSocket channel.** ONLY when the\n BROWSER must send up the socket without a page action: live chat messages,\n typing indicators, cursors/presence.\n\nA screen being called a \"room\", or being multi-user, or being live is NOT a\nreason to use a channel. If the browser isn't pushing frames up, it's SSE.\n\n## Level 1 — live updates (event-hub SSE, the default)\n\nTwo halves; both are required or nothing arrives.\n\n**Backend — publish after every write.** In each create/update/status function,\nAFTER the DB write, publish the changed row on a topic:\n\n```ts\nconst lot = await kysely\n .updateTable('lot')\n .set({ status: 'sold' })\n .where('id', '=', input.lotId)\n .returning(['id', 'status', 'currentBid', 'updatedAt'])\n .executeTakeFirstOrThrow()\nawait eventHub.publish('lot-updated', null, { topic: 'lot-updated', data: lot })\nreturn lot\n```\n\n**A topic is PUBLIC — publish a projection, never `returningAll()`.** The generated\n`/events/:topic` route is wired `auth: false` with a sessionless handler, so anyone who can\nreach the origin can subscribe to any topic name and read every frame on it. `returningAll()`\nthen ships the whole row — `reservePrice`, `sellerId`, internal notes, whatever the table\ngrows next — to unauthenticated subscribers, and it does it silently because the RPC's own\n`output` schema never sees the event payload. List the columns the topic is FOR, the way the\nexample does. If a change genuinely has per-viewer content, it does not belong on a topic:\npublish an id-only \"something changed\" frame and let each client refetch through an\nauthenticated RPC that applies its own permissions.\n\nThe **2nd arg is the channel to EXCLUDE** from the broadcast: pass `null` from a\nnormal HTTP/RPC write (there is no one to skip); pass `channel.channelId` ONLY\nwhen you publish from INSIDE a channel handler, or the sender gets an echo of its\nown update. `eventHub` is already injected — do not wire it.\n\n**Frontend — subscribe over SSE.** The generated\n`PikkuRealtime.subscribeToTopic(topic, handler)` opens an SSE stream to the\nbuilt-in `/events/:topic` route. Seed state from a normal query, then patch it as\nevents arrive; the event is the `{ topic, data }` envelope, so read `.data`.\n\n```tsx\nimport { useEffect } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { realtime } from '../lib/pikku'\n\nexport function useLiveLots() {\n const queryClient = useQueryClient()\n\n useEffect(() => {\n const subscription = realtime.subscribeToTopic('lot-updated', () => {\n queryClient.invalidateQueries({ queryKey: ['listLots'] })\n })\n return () => subscription.close()\n }, [queryClient])\n}\n```\n\n**Invalidate; do not hand-patch the cache.** The generated hooks key a query as\n`[name, input]` — `['listLots', { status: 'open', cursor: undefined }]`, one entry per set of\narguments — so `setQueryData(['listLots'], …)` writes to a key nothing reads and the screen\nnever changes. `invalidateQueries({ queryKey: ['listLots'] })` prefix-matches, so it refreshes\nevery variant of that list whatever input each one was fetched with.\n\nPatching also has to know the payload's shape, and a list RPC returns\n`ListOutput<Lot>` — `{ rows, nextCursor, totalCount? }`, not `Lot[]` — so a `rows.map(...)`\nupdater is reading `.map` off an object. Refetching sidesteps both, and it re-applies the server's own filtering,\nwhich a locally patched row does not: a lot that just moved to `sold` may no longer belong in\nan \"open lots\" list at all.\n\n`subscribeToTopic` returns `{ close }` — ALWAYS close on unmount or you leak the\nstream. Never hand-roll an `EventSource`.\n\n## Level 2 — two-way channel\n\nOnly when the client pushes up the socket. The backend channel lives in its own\n`*.channel.ts` with `onConnect`/`onMessage` handlers:\n\n```ts\nimport { pikkuChannelFunc, wireChannel } from '#pikku/channel'\n\nexport const onMessage = pikkuChannelFunc<{ text: string }>({\n func: async ({ eventHub }, input, { channel, session }) => {\n const message = { id: crypto.randomUUID(), text: input.text, userId: session!.userId }\n await eventHub.publish('room', channel.channelId, { topic: 'room', data: message })\n return message\n },\n})\n\nwireChannel({ name: 'room', route: '/room', auth: true, onMessage })\n```\n\nThe frontend opens it with `PikkuRealtime.connectToChannel(path)`, which returns\na socket you both `.send(...)` on and read via `onmessage`:\n\n```tsx\nuseEffect(() => {\n const socket = realtime.connectToChannel('/room')\n socket.onmessage = (event) => appendMessage(JSON.parse(event.data))\n return () => socket.close()\n}, [])\n```\n\nPublish server→client fan-out from a channel handler with\n`eventHub.publish(topic, channel.channelId, envelope)` — the 2nd arg excludes the\nsender, so the browser that sent the message does not receive its own echo.\n\n## Do NOT\n\n- Do **not** grep the project for existing SSE/eventHub infra to reverse-engineer\n or copy — the patterns above ARE the template (same rule as never reading\n `.gen.ts` to learn an API).\n- Do **not** write a custom `sse: true` HTTP route or a bespoke `EventSource` for\n an ordinary live feed — the event-hub covers it. (A dedicated `sse: true` route\n is only for a long-job PROGRESS stream, and is not needed for an initial build.)\n- Do **not** use a WebSocket channel for a live board/ticker/room — that is SSE.\n A channel is for client→server push (chat/presence) ONLY.\n- Do **not** forget the backend `eventHub.publish(...)` — a subscribed frontend\n with no publisher is a silent, empty stream.\n", "pikku-scenario/references/persona-run.md": "# Running a persona as a virtual user\n\n`pikku persona run <environment> <persona>` signs a declared persona in over the\napp's real auth and works the API in character, driven by a model. A persona\nwhile running **is** the virtual user — there is no second declaration for it.\n\n**It is not a test runner.** It asserts nothing, and a green run proves nothing:\nwhat it produces is _findings_, and their absence is only ever \"not this time,\nnot with this seed\". Findings set exit code 1, so a run can gate a pipeline;\ngiving up on a goal does not, because that is a user being a user.\n\nEverything it needs is already in the project — the catalogue is the function\nmeta, the intents are the scenarios' own prose, the identity is the persona\nsigning in, the scopes come from their declared roles. The only new input is\nwhich person to be.\n\nDeclaring personas — persona versus actor, `definePersonas`, materialised\nactors — is in the skill itself, under **Personas and actors**. This is the\nrunning half.\n\n## The shape of a run\n\n```bash\nSCENARIO_ACTOR_SECRET=… pikku persona run local shopper\nSCENARIO_ACTOR_SECRET=… pikku persona run local shopper -d careless --seed 42\nSCENARIO_ACTOR_SECRET=… pikku persona run staging auditor \\\n --goals \"reconcile the order totals\" --steps 80 --out runs/auditor.json\n```\n\nBoth arguments are required positionals: the environment key from\n`environments`, then the persona id. A run needs a model — `--model`, or\n`scenarios.model` in `pikku.config.json` — and an AI provider in the\nenvironment (`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or `LITELLM_PROXY_URL` +\n`LITELLM_API_KEY`).\n\n| Flag | Effect |\n| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--disposition` / `-d` | How they behave. Overrides the persona's own |\n| `--goals` | Comma-separated, in your words — run _alongside_ the persona's own and the ones derived from scenarios |\n| `--steps` | Model turns before it stops (default 40) |\n| `--mutations` | Non-read calls before it stops |\n| `--duration` | Wall clock before it stops, e.g. `30m` |\n| `--seed` | Replay — the same seed schedules the same run |\n| `--model` | The model they think with |\n| `--allow-approval` | Offer the endpoints the app marked as needing a human's approval. Off by default: those are the ones that spend money |\n| `--skip-role-check` | Start without verifying declared roles against the stage |\n| `--api-url` | Override the environment's `apiUrl`, for a target that only exists at run time. It replaces the url, not the environment's classification — see below |\n| `--out` | Write the whole run — every step, response and finding — as JSON |\n\n## The dispositions\n\nA disposition is a bundle of instructions and mechanical dials (move weights,\ntemperature, repeat and re-read rates). `tuning` on the persona adjusts those\ndials without replacing the character — a tuned `careless` user is still\ncareless. Passing `--disposition` drops the persona's `tuning`, because you\nasked to run them differently rather than to bend their dials into another\nshape.\n\n| Disposition | Who that is |\n| ------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| `realistic` | The default. A competent user reading schemas and entering plausible values |\n| `careless` | Busy, interrupted, half-remembering; submits twice, enters odd-but-legal values. **Where most production bugs actually live** |\n| `newcomer` | First time here, holds no ids in their head, must find a path from the lists that exist (`emptyMemory`) |\n| `stale` | Working from old notes — reaches for ids that may no longer resolve, to see how the product says so |\n| `auditor` | Reconciling, not achieving: reads one fact from every endpoint claiming to know it and reports disagreement. Read-only |\n| `adversarial` | Probing whether the boundaries are enforced. Inverted oracle — a 2xx from something it should not reach is the finding |\n| `accountable` | Doing the job for real. The **only** disposition production accepts |\n\n## Credentials, and which one wins\n\nThree variables, checked in this order. None of them belongs in\n`pikku.config.json`.\n\n1. **`FABRIC_OPERATOR_TOKEN`** — what a deployed stage accepts. Asymmetric, and\n it needs no account the target would not otherwise have, so it wins over the\n other two when both are present.\n2. **`PIKKU_PERSONA_SECRETS`** — `id=secret,id=secret`, already-derived\n per-persona credentials. Hand a run only the personas it should be able to\n be; asking for one outside the list is refused by name rather than falling\n through to the root. Mint them with `pikku persona secret [personas...]` —\n naming none mints all.\n3. **`SCENARIO_ACTOR_SECRET`** — the root secret, which derives every persona's\n credential and is therefore entitled to all of them. Only `pikku dev` serves\n the endpoint it opens.\n\n## Production is opt-in, twice\n\nA persona's `environments` omitted means every configured environment **except**\nthose flagged `production: true` — nothing reaches production by being\nforgotten. Naming one requires `disposition: 'accountable'`.\n\nThat rule is checked twice on purpose: the inspector checks the declaration at\nbuild time, and sign-in re-checks the **effective** disposition — the persona's\nown, or whatever `--disposition` replaced it with — before the run starts. So\n`--disposition adversarial` cannot point an accountable persona at production.\nThe build check trusts the file; the run check does not trust which artifact got\ndeployed.\n\n**That rule is keyed on the environment's name, not its url.** `production:\ntrue` is a label a person wrote in `pikku.config.json`; nothing can tell from a\nurl whether real customers are behind it. `--api-url` replaces the url and keeps\nthe classification, so a non-production environment repointed at a production\nhost is still treated as non-production, and an adversarial persona will happily\nrun against it. The flag is for a target that only exists at run time — a\nfreshly provisioned sandbox. Point it anywhere else and the guard above is not\nprotecting you.\n\n## The role check happens before the first step\n\nA run reads its own roles back from the stage and compares them to what the\npersona declared. It refuses on a mismatch, before anything runs — findings\nfrom a persona whose roles drifted are about the seed, and reading them as\nproduct bugs is how a whole run gets thrown away. A stage that reports no roles\nwarns and runs unverified. `--skip-role-check` is for a target whose auth\nreports roles somewhere pikku cannot read; findings from such a run may be seed\ndrift.\n\n## The other subcommands\n\n| Command | What it answers |\n| ------------------------------------ | ------------------------------------------------------------------------------------------ |\n| `pikku persona list` | Who is declared — who each one is, what they may do, what they want |\n| `pikku persona sync <environment>` | Which personas that environment will provision, with which roles, and why any were skipped |\n| `pikku persona secret [personas...]` | Mint per-persona credentials from the root secret |\n\n`sync` **reports**; it does not provision. The CLI has no connection to a\ndeployed environment's database, so the provisioning happens in the deployment —\npass the generated personas to `pikkuFabric` from `@pikku/better-auth`.\n\n## What NOT to do\n\n- **Do not treat a clean run as a pass.** Nothing was asserted. Use scenarios\n for the things that must hold.\n- **Do not run a persona declared `runnable: false`**, or one whose `account`\n names a provider. The first is someone who exists to be acted upon — running\n her races the scenario that bans her — and the second needs a human at a\n consent screen. Both are refused before sign-in rather than partway through.\n- **Do not use `--api-url` to reach a production host from a non-production\n environment.** The disposition guard reads the named environment's\n `production` flag, not the url you pointed it at, so nothing will stop you.\n- **Do not put any of the three credentials in `pikku.config.json`.** They are\n environment variables.\n- **Do not pass `--allow-approval` casually.** The endpoints behind it are the\n ones the app marked as needing a human because they spend money.\n- **Do not read a finding from a run started with `--skip-role-check` as a\n product bug** until the roles are confirmed some other way.\n- **Do not expect `--goals` to replace the persona's goals.** They are appended;\n a run that replaces Susan's goals is not Susan.\n", "pikku-scenario/SKILL.md": "---\nname: pikku-scenario\ndescription: >-\n Use when writing or running Pikku scenarios, running a persona as a virtual user, or when\n asked to test Pikku functions or improve coverage. A scenario (pikkuScenario) drives the app\n the way users do — steps run as actors over the real transport against a running server — so a\n flow doubles as an e2e test and a staged/production health check. Covers scenario.do /\n expectEventually / expectError / expectService / expectScore, declared steps via\n pikkuScenarioStep (browser steps driven by @pikku/playwright) written as intent rather than\n clicks, personas / actors / environments in pikku.config.json, SCENARIO_ACTOR_SECRET, the\n `pikku scenario list|run` and `pikku persona run|list|sync|secret` commands, and live coverage\n via `pikku dev --coverage`. TRIGGER when: user asks about scenarios, testing a Pikku function,\n coverage, e2e flows, browser/UI e2e, health checks, personas, virtual users, or adversarial\n runs against a stage. DO NOT TRIGGER when: user asks about running an existing suite (use\n Bash) or CI config.\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## Pick the reference\n\n| You are… | Read |\n| ---------------------------------------------------------------- | --------------------------- |\n| Writing or running scenarios | this skill |\n| Running a persona as a model-driven virtual user against a stage | `references/persona-run.md` |\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/scenarios'\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 `pikkuAgentScorer` (grades in code) or\n`pikkuAgentJudge` (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(\n 'asks for a summary',\n 'runAssistant',\n {\n prompt: data.prompt,\n },\n { actor: actors.user }\n)\n\nawait scenario.expectScore('answered briefly', runId, 'brevity', {\n atLeast: 0.8,\n})\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/scenarios'\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 shopper is browsing the shop` |\n| `When clicks the category filter` | `When 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/scenarios'\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 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### What language the prose is in\n\nA scenario carries two kinds of text, and they do not share a language.\n\n**Identifiers are English.** The exported const (`buysAnApple`,\n`credentialFeature`), the step's `name` — which is its `pikkuFuncId`, the typed\nstring the generated step map is keyed by — the file name, and every helper in\n`*.browser.ts`. These bind to generated code and to `pikku scenario list`; they\nare English in every project regardless of who the product is for or what\nlanguage the team speaks. There is no setting that changes this.\n\n**Prose follows `metaLocale` in `pikku.config.json`** (default `en`). That is a\nstep's `description` and `template`, a feature's `name` and `description`, a\nscenario's `title`, and the positional step names passed to\n`scenario.given/when/then`. Read the field before you write any of them.\n\nThis split is the same one the feature table already states — _the export\nidentifier is the feature's id; `name` is the human-readable label_ — applied to\nlanguage. The report is the deliverable, and it is read by the team; the\nidentifier is an API, and it is read by the toolchain.\n\n```typescript\n// pikku.config.json: { \"metaLocale\": \"de\" }\nexport const buysAnApple = pikkuScenarioStep<\n { qty: number },\n { orderId: string }\n>({\n name: 'buysAnApple', // identifier — English, always\n description: 'kauft einen Apfel', // prose — follows locale\n template: 'kauft {qty} Äpfel', // prose — follows locale\n actor: true,\n default: async (_services, { qty }, { actor }) =>\n await actor.invoke('placeOrder', { qty }),\n})\n```\n\nNote what does **not** change: `placeOrder` is still `placeOrder`, and the file\nis still `apple.scenario.ts`.\n\nA product with a non-English UI is not on its own a reason to set `metaLocale` — that\nis the app's language, not the team's. Ask, or leave it `en`.\n\n**Where a non-`en` `metaLocale` still shows English, today.** The reporter composes a\nsentence as `<Keyword> <actor> <template>` (`composeStepProse`), and the keyword is\nan English literal. The Console translates the Given/When/Then keywords into its own\nUI language; the CLI reporter does not, so `metaLocale: \"de\"` gives you German step\nprose inside an English frame — `Given shopper kauft 1 Äpfel`. Write templates that read\nacceptably in that frame rather than trying to defeat it. A second gap: where a\nfunction or scenario declares no `title`, the Console falls back to splitting the\n**identifier** into an English-looking label (`toEnglishName`), so under a\nnon-`en` `metaLocale` meta is worth authoring rather than leaving to the fallback.\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 // Both bindings run as the persona, so the step declares one and the runner\n // injects `wire.actor` — non-optional in every binding.\n actor: true,\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 // Through the actor, not through a `rpc` service — see \"What a step is given\".\n default: async (_services, { orderId }, { actor }) => ({\n status: (await actor.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/scenarios'\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 actor: true,\n default: async (_services, { qty }, { actor }) => {\n return await actor.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 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- **A step that runs as somebody declares `actor: true`**, and the runner injects `wire.actor` — non-optional inside every binding, with no guard to write and nothing to unwrap. A `browser` binding implies it, because a window is opened as somebody. Leave it off for a step with no persona to be: an assertion over what an earlier step returned, or one that posts credentials precisely because it must not reuse an actor's session. Dispatching a step that declared it without `{ actor: actors.x }` fails before the body runs (`ScenarioActorRequired`); a step that did not declare it has no `actor` on its wire at all.\n- **`env` is optional on the wire**, because most steps need nothing from it. Narrow it with `requireScenarioEnv(scenarioStep)` from `#pikku/scenario` rather than a local guard — it names the step and says 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- **Never write the actor into the prose.** The reporter renders the actor as the sentence's subject, so a step authored as `` `'sam' creates the client` `` run as `{ actor: actors.sam }` reads \"Given sam 'sam' creates the client\" — and the hardcoded name desyncs the moment the call site changes actor. Write a bare third-person predicate (`creates the {name} client`) and let the actor supply the subject. Prose that opens with its own actor's key — quoted, capitalised or possessive — is `PKU681`; naming someone **else** mid-sentence (\"sends nadia an invite\") is ordinary prose and is left alone, as is an actor keyed after a role noun used as a noun (\"creates the admin client\" as `actors.admin`).\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#### What a step is given\n\nA step has the signature of an ordinary pikku function, which makes it look as\nthough it runs where the application runs. It does not — **it runs in the CLI\nprocess**, and the services object is built there, by hand:\n\n```typescript\n{ logger, workflowService, workflowRunService, agentRunner? }\n```\n\nThat is the whole list. There is no `kysely`, no `variables`, no `secrets`, and\nnone of the project's own singleton or wire services. A step that destructures\none gets `undefined` and fails on first use — `Cannot read properties of\nundefined (reading 'selectFrom')` — which reads like a broken container and is\nnot.\n\n`rpc` is the trap worth naming, because it is present and it throws. It is a\n`guardRpc` whose every member refuses:\n\n> Scenario tried to run 'getOrder' as an internal step. Every workflow.do in a\n> scenario must carry { actor: actors.x } so it executes against 'local'.\n\nThe same guard covers `rpc.agent.run/stream/resume/approve/interrupt` and\n`startWorkflow`.\n\nThis is the design, not a gap: **everything a step touches of the application\ngoes over the wire as somebody.** A test that could reach into the database\nwould be testing a different program from the one a person uses. So there are\nexactly three ways in, and they are all through the actor:\n\n- `actor.invoke(name, data)` — typed over the exposed RPC map, carrying the\n actor's session. Declare `actor: true` and destructure it off the wire.\n- `.invokeRaw(name, data, { headers })` — same call, reporting\n `{ status, ok, body }`, for when the refusal is the assertion.\n- a plain `fetch` against `requireScenarioEnv(scenarioStep).apiUrl`, for\n anything not an RPC — a websocket, a file upload, a webhook.\n\nTwo consequences follow, and both shape how steps get written:\n\n- **A step cannot observe anything the app does not publish.** If a test needs a\n fact the client never sees, the fix is to emit it on the stream or expose it\n as an RPC — which usually improves the product, since a client debugging the\n same problem needed it too.\n- **`agentRunner` is conditional.** It is built only when the project declares\n agents, and `createDevAgentRunner` needs a base URL _and_ a key together\n (`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the LiteLLM pair). With a key alone\n it returns nothing and `agentRunner` is `undefined`, so `actor.converse`\n fails before the persona says anything. A suite that would rather own its own\n model can pass an `llm` to `runConversation` instead of relying on this one.\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<\n { path: string },\n { 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 (_services, _data, { actor }) => ({\n url: (await actor.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#### Locate by message key, never by rendered copy\n\nIf the app is translated, **no step may contain a user-visible string.** `getByLabel('Full Name')` passes only while the browser happens to render the base locale, and any copy edit turns it into a selector timeout that points at the wizard rather than at the rename that caused it — the test looks broken where it is merely stale.\n\nThe message catalogue already holds the string under a key. Read it from there. Type the lookup off the catalogue JSON so a renamed or misspelled key is a **compile** error rather than a run-time timeout:\n\n```typescript\n// tests/scenarios/i18n.ts\nimport type messages from '../../../../apps/web/messages/en.json'\n\nexport type MessageKey = keyof typeof messages\n\nexport const t = (key: MessageKey, locale = baseLocale): string => {\n /* … */\n}\n```\n\n```typescript\nawait page.getByLabel(t('jobs_apply_fullname')).fill(identity.name)\nawait page\n .getByRole('button', { name: t('jobs_apply_submit'), exact: true })\n .click()\n```\n\n- Type off `messages/<baseLocale>.json`, **not** the generated Paraglide output — `i18n/paraglide/` is build output, so typing against it makes the tests unbuildable until the app has been built. The JSON is the tracked source.\n- Fall back to the base locale for a key a locale has not translated. That is what Paraglide does at run time, so a helper that throws instead would disagree with the screen the test is looking at.\n- This is not only about locators. A copy literal passed to a **project helper** (`pick('Where would you like to work?', …)`) reaches the DOM the same way, and so does a pane name quoted back in a failure message. `pikku fabric validate` scans every string in a `*.steps.ts` / `*.scenario.ts` against the base catalogue and errors on any verbatim match, wherever it sits — except comments, and the `name` / `description` / `template` declared directly on a `pikkuFeature`, `pikkuScenario` or `pikkuScenarioStep`, which are Console meta written in the project's `locale` rather than app copy.\n- A regex locator (`{ name: /^Next$/i }`) hides the literal but not the problem. `{ name: t('key'), exact: true }` is both stricter and locale-correct.\n- Strings the catalogue does not own — a test id, a fixture filename, a seeded value — stay literal. The catalogue is the test for whether something is copy.\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\n#### Declaring personas in TypeScript\n\n`definePersonas({ … })` is the code form of the block above, and there may be\n**one call in the whole codebase** — one place to read the set from, one place\nto add to it. A second anywhere, including in the same file, is a critical.\nGenerated files are exempt and never claim the slot.\n\n> [!WARNING]\n> The declaration is **read from source, never evaluated** — the CLI writes it\n> to JSON that a deployed stage carries without the app. So every value has to\n> be statically knowable, and a value that is not comes out as `undefined`\n> rather than as an error. Only `name` is checked, so a computed `personality`,\n> `jobTitle` or `description` is dropped in silence and the persona runs with a\n> blank temperament.\n\nWhat that admits and what it does not:\n\n```typescript\npersonality: 'Wound up and short with it.' // read\npersonality: `Wound up and short with it.\n Says what she wants in a few blunt words.` // read — no ${} in it\npersonality: 'Wound up. ' + 'Short with it.' // dropped, silently\npersonality: TEMPERAMENTS.impatient // dropped, silently\n```\n\nA no-substitution template literal is a string literal as far as the reader is\nconcerned, so it is the way to write a long personality across several lines —\nnot a concatenation, and not a `prettier-ignore`d single line. Its newlines and\nleading indentation are kept verbatim and reach the model that way, which is\nharmless but worth knowing before you align it to the surrounding code.\n\nOne more thing worth knowing before writing a rich persona: **`actor.converse`\nbuilds its prompt from `name`, `jobTitle`, `personality` and the scenario's\n`task` only.** Fields like `disposition`, `goals` and `roles` are read and\nstored, and the console shows them, but they do not reach the conversing\npersona's instructions. Anything that must shape how someone talks belongs in\n`personality` or in the task.\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### The same actors sign a human in\n\nDeclared actors are not only for automated runs. `signInPath` is Better Auth's\n`actor` plugin (see `pikku-auth`, a separate install), which any caller can post to — so the\nfrontend gets a one-click \"Sign in as …\" switcher over the **same** list, and an\napp can be reviewed as each kind of user without anyone knowing a seed password.\n\nThe sandbox dev server bakes both halves into the frontend from the declared\npersonas: `VITE_DEV_ACTORS` (the JSON actor list) and `VITE_DEV_ACTOR_SECRETS`\n(`{ email: credential }`, one per persona — `SCENARIO_ACTOR_SECRET` itself never\ngoes in a bundle; see **pikku-auth**). Neither var is set in a production\nbuild, so the control renders nothing there — but gate the reads on your\nbundler's dev flag anyway (`import.meta.env.DEV ? … : undefined`) so no\ncredential reaches a production bundle in the first place.\n\nDo not hand-roll the switcher: `useDevActors()` (`pikku-react`, a separate install) is the logic and\n`<DevActorSwitcher />` from `@pikku/mantine/dev` is a ready rendering of it.\n`pikku fabric validate` **requires** any frontend with a login screen to ship\none — without it a reviewer is locked out of their own sandbox.\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\n```\n\n`scaffold.scenarios` is a boolean or `{ path? }` — whether the surface exists\nand where it is written. A bare string is **rejected by the config loader**, not\nreinterpreted: under a shape where a string could be a path, silently reading\none as a flag would 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>\": {/* FunctionCoverageReport */},\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 step named `kauftEinenApfel` / a `vorgang` table | Identifiers are English in every project. The German belongs in `description` / `template`, and only when `pikku.config.json` sets `metaLocale`. |\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| `getByLabel('Full Name')` in a translated app | Passes only in the base locale, and a copy edit breaks it as an unexplained timeout. Locate by message key. |\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-seo/SKILL.md": "---\nname: pikku-seo\ndescription: >-\n On-page SEO rules for the app's PUBLIC pages: per-route head() titles and meta descriptions, Open Graph tags, one-h1 heading hierarchy, semantic/crawlable markup, JSON-LD on the landing page, and noindex for the logged-in area.\n TRIGGER when: building or reworking any public page (landing, pricing, about, blog/content pages), writing page titles or meta tags, or the user asks about SEO / Google / discoverability / social sharing previews.\n DO NOT TRIGGER when: working on logged-in /app screens (they are noindexed — only the one robots rule below applies), backend functions, database, or deployment.\ninstallGroups: [client]\n---\n\n# SEO Rules\n\nApps render SSR from the edge, so crawlers see full HTML — the ranking work is\ngetting the on-page signals right while you build. These rules apply to PUBLIC\nroutes only (the landing page and any marketing/content pages). The logged-in\n`/app` area is private: it gets `noindex` and nothing else from this skill.\n\n## Per-route head() — every public route, no exceptions\n\nTitles and descriptions live in TanStack Start's `head()` on the route, merged\nroot → leaf (the leaf's title/meta win). The root route already carries the\nsite-wide defaults and OG tags; every public page you add MUST override both:\n\n```tsx\nexport const Route = createFileRoute('/pricing')({\n head: () => ({\n meta: [\n { title: 'Pricing — Acme Scheduling' },\n {\n name: 'description',\n content:\n 'Simple per-seat pricing for Acme Scheduling. Start free, upgrade when your team grows — no setup fees, cancel anytime.',\n },\n { property: 'og:title', content: 'Pricing — Acme Scheduling' },\n { property: 'og:description', content: 'Simple per-seat pricing. Start free.' },\n ],\n }),\n component: PricingPage,\n})\n```\n\n`head()` strings are plain strings (they do not go through the Mantine i18n\ngate) — write real copy for THIS app, in the app's voice.\n\n- **Title**: unique per page, 50–60 characters, the page's primary topic first,\n brand at the end (`Topic — AppName`). The template's `__APP_TITLE__` default\n must never survive the rebrand, on any page.\n- **Description**: unique per page, 150–160 characters, states the concrete\n value of the page in plain language — a reason to click, not a keyword list.\n- **Dynamic public pages** (e.g. a public detail page) build both from loader\n data: `head: ({ loaderData }) => ({ meta: [{ title: `${loaderData.name} — AppName` }, ...] })`.\n- **Never invent URLs**: the deployed domain is unknown at build time, so do\n NOT emit `canonical`, `og:url`, or `og:image` pointing at a made-up domain —\n omit them (same principle as the `/api` serverUrl rule). `og:image` only if a\n real asset exists in the app.\n\n## Logged-in area = noindex\n\nThe `/app` route (the authenticated layout route) gets exactly one meta entry:\n\n```tsx\nhead: () => ({ meta: [{ name: 'robots', content: 'noindex' }] })\n```\n\nNever noindex a public page, and never put per-page SEO effort into `/app`\nscreens — they are invisible to crawlers by design.\n\n## Headings — exactly one h1 per page\n\n- Every page has EXACTLY ONE h1 (`<Title order={1}>` in Mantine, `<h1>` in\n Tailwind) and it names the page's primary topic — aligned with the title tag,\n not identical boilerplate.\n- Logical hierarchy below it: h1 → h2 → h3, no skipped levels, headings\n describe the content under them. Never pick a heading level for its font\n size — set the size on the correct level (`<Title order={2} fz=\"xs\">`).\n\n## Crawlable, semantic markup\n\n- Landmarks on public pages: `<nav>`, `<main>`, `<footer>` (Mantine: `component=\"nav\"` etc.).\n- Navigation between public pages uses real links (`<Link>`/`<a href>`) with\n descriptive anchor text — crawlers follow hrefs; a `div onClick` navigation\n is invisible to them. No public page may be orphaned: every public page is\n reachable by link from the landing page (directly or via nav/footer).\n- Every meaningful `<img>` has alt text describing the image; decorative images\n get `alt=\"\"`. Prefer descriptive file names for real assets.\n- Readable URLs: public routes are lowercase, hyphen-separated, and named for\n their content (`/pricing`, `/how-it-works`) — never `/page2` or query-param\n navigation.\n\n## JSON-LD on the landing page\n\nThe landing page carries one structured-data script describing the product.\nOnly mark up what is visibly true on the page — never invent ratings, reviews,\nor offers (fake schema is a Google penalty, not a boost):\n\n```tsx\nhead: () => ({\n meta: [\n /* title + description as above */\n ],\n scripts: [\n {\n type: 'application/ld+json',\n children: JSON.stringify({\n '@context': 'https://schema.org',\n '@graph': [\n { '@type': 'Organization', name: 'Acme Scheduling', description: '…' },\n { '@type': 'WebSite', name: 'Acme Scheduling' },\n ],\n }),\n },\n ],\n})\n```\n\nAdd further types only when the page genuinely IS that thing and shows the\nrequired fields: `FAQPage` for a real FAQ section, `Article` for a blog post\n(headline, datePublished, author), `Product`/`Offer` for a real price list.\nOmit `url`/`logo` fields — deployed domain unknown (see \"never invent URLs\").\n\n## Verify\n\nOpen each PUBLIC page and read its `<head>`: a title, a meta description, and\nexactly one `h1`. A public page is not done while any of the three is missing.\nSigned-in pages under `/app` are exempt once `noindex` is set — they are not\nindexed, so their head tags do not matter.\n\n## Don'ts\n\n- No keyword stuffing — write for the reader; one clear topic per page.\n- Don't duplicate the same title/description across pages (worse than absent).\n- Don't render SEO-critical copy only after client-side effects — it must be\n in the SSR HTML (loader data is fine; `useEffect`-fetched content is not).\n- Don't add robots.txt/sitemap plumbing — the platform owns that layer.\n", "pikku-service-backends/references/aws.md": "# AWS (`@pikku/aws-services`)\n\n```bash\nyarn add @pikku/aws-services\n```\n\nAWS-backed implementations of the content, queue, and secret interfaces.\n\n## `S3Content` — ContentService\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\nEvery method takes a single **args object**, matching the shared `ContentService`\ninterface. 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`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>` — it uses\n`bucketName` as the **host**. For signed content that value must therefore be\nyour CloudFront domain, not a plain bucket name, which means the same config\nfield is doing two jobs.\n\nPresigned upload URLs expire after a fixed **3600s**, not configurable through\nthe 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## `SQSQueueService` — QueueService\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\n- `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — returns SQS's `MessageId`\n- `getJob()` — always **throws**\n\nThe queue URL is `queueUrlPrefix + queueName`, so the name in `wireQueueWorker`\nhas to match the SQS queue exactly.\n\nConstraints inherited from SQS, enforced in `add`:\n\n- `options.delay` is in **milliseconds**, 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```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\n## `AWSSecrets` — SecretService\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\nSDK's default provider chain (instance role, env, profile) supplies those.\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>>` — missing keys are omitted rather than thrown\n- `hasSecret(SecretId: string): Promise<boolean>` — performs a full fetch\n- `setSecret` / `deleteSecret` — **not implemented**; they throw\n", "pikku-service-backends/references/backblaze.md": "# Backblaze B2 (`@pikku/backblaze`)\n\n```bash\nyarn add @pikku/backblaze\n```\n\n`B2Content` implements `ContentService` over Backblaze B2.\n\n```typescript\nimport { B2Content } from '@pikku/backblaze'\n\nconst content = new B2Content(config: B2ContentConfig, logger: Logger)\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\nEvery method takes a single **args object**, matching the shared `ContentService`\ninterface. 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\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\nBecause `writeFile` drains the whole stream into a `Buffer` before uploading\n(B2's upload endpoint needs a SHA-1 and a content length up front), large uploads\nshould go through `getUploadURL` and be sent by the client directly.\n\n```typescript\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-service-backends/references/mongodb.md": "# MongoDB (`@pikku/mongodb`)\n\n```bash\nyarn add @pikku/mongodb\n```\n\n## `PikkuMongoDB` — connection wrapper\n\nEvery service below takes a `Db`, so the wrapper is constructed and initialised\nfirst.\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| `MongoDBAgentStorageService` | `AgentStorageService`, `AgentRunStateService` | 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 of them take a `Db` in the constructor and have an `init()` method that\ncreates the collections and indexes. **Await it** — a service used without it\nbehaves like an unindexed collection.\n\n## `MongoDBSecretService`\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## 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-service-backends/references/redis.md": "# Redis (`@pikku/redis`)\n\n```bash\nyarn add @pikku/redis\n```\n\nRedis-backed implementations of Pikku's core service interfaces, using\n[ioredis](https://github.com/redis/ioredis). Every service accepts a Redis\nconnection — an ioredis `Redis` instance, `RedisOptions`, or a connection\nstring — in its constructor. None of them need an `init()` call.\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\nThere is no Redis implementation of `AgentStorageService` — AI conversation\nstorage is MongoDB-only.\n\n## `RedisSecretService`\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## 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-service-backends/references/schema.md": "# Schema validation (`@pikku/schema-ajv`, `@pikku/schema-cfworker`)\n\nTwo implementations of `SchemaService` from `@pikku/core`. Pikku uses whichever\none is wired to validate function inputs and outputs against the schemas codegen\nderives from your function definitions.\n\n```bash\nyarn add @pikku/schema-ajv # default for Node.js\nyarn add @pikku/schema-cfworker # Cloudflare Workers\n```\n\nBoth expose the same four methods:\n\n- `compileSchema(name: string, schema: any): void` — compile and register under `name`\n- `validateSchema(schemaName: string, json: any): void` — throws on failure\n- `getSchemaNames(): Set<string>`\n- `getSchemaKeys(schemaName: string): string[]` — top-level property keys, or `[]` if the schema has no `properties`\n\nOn `compileSchema` the first argument is the **name** and the second the schema —\nthe parameter is called `schema` in the source, which reads backwards.\n\n## `AjvSchemaService`\n\n```typescript\nimport { AjvSchemaService } from '@pikku/schema-ajv'\n\nconst schema = new AjvSchemaService(logger: Logger)\n```\n\nBacked by [AJV](https://ajv.js.org/).\n\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.\n- `ajv-formats` is registered, so `format` keywords (`email`, `uuid`,\n `date-time`) are enforced.\n\n## `CFWorkerSchemaService`\n\n```typescript\nimport { CFWorkerSchemaService } from '@pikku/schema-cfworker'\n\nconst schema = new CFWorkerSchemaService(logger: Logger)\n```\n\nBacked by [@cfworker/json-schema](https://github.com/cfworker/cfworker), which\nuses no `eval` or `new Function` and so runs where AJV cannot.\n\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\n## Wiring either one\n\n```typescript\nconst createSingletonServices = pikkuServices(async (config) => {\n const logger = new ConsoleLogger()\n const schema = new AjvSchemaService(logger)\n return { config, logger, schema }\n})\n```\n", "pikku-service-backends/SKILL.md": "---\nname: pikku-service-backends\ndescription: >-\n Use when picking or wiring a backend for one of Pikku's core service interfaces — ContentService\n (S3, Backblaze B2), QueueService (SQS), SecretService (AWS Secrets Manager, Redis, MongoDB),\n SchemaService (AJV, cfworker), ChannelStore, EventHubStore, WorkflowService, SessionStore or\n AgentRunService (Redis, MongoDB). Covers which backend to choose, what each one silently does\n differently, and the failures they swallow. TRIGGER when: code uses S3Content, B2Content,\n SQSQueueService, AWSSecrets, RedisChannelStore, RedisSecretService, MongoDBChannelStore,\n PikkuMongoDB, AjvSchemaService or CFWorkerSchemaService, or the user asks how to store files,\n secrets, channel state or sessions. DO NOT TRIGGER when: defining service factories themselves\n (use pikku-services), SQL via Kysely (use pikku-kysely), or the Lambda/Cloudflare runtimes\n themselves (use pikku-deploy).\ninstallGroups: [core]\n---\n\n# Pikku Service Backends\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\nConstructor shapes and method signatures come from `pikku doc` — run\n`pikku doc --ai` for the installed surface. This skill is the part the compiler\ncannot tell you: which backend implements which interface, and what changes when\nyou swap one for another.\n\n`pikku-services` covers how to build and wire a service. This covers what to put\nbehind the interface.\n\n## Pick a backend\n\n| Interface | Backends | Package |\n| --- | --- | --- |\n| `ContentService` | `S3Content`, `B2Content` | `@pikku/aws-services`, `@pikku/backblaze` |\n| `QueueService` | `SQSQueueService` | `@pikku/aws-services` |\n| `SecretService` | `AWSSecrets`, `RedisSecretService`, `MongoDBSecretService` | `@pikku/aws-services`, `@pikku/redis`, `@pikku/mongodb` |\n| `SchemaService` | `AjvSchemaService`, `CFWorkerSchemaService` | `@pikku/schema-ajv`, `@pikku/schema-cfworker` |\n| `ChannelStore`, `EventHubStore` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |\n| `PikkuWorkflowService`, `WorkflowRunService` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |\n| `SessionStore`, `AgentRunService`, `DeploymentService` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |\n| `AgentStorageService`, `AgentRunStateService` | MongoDB **only** | `@pikku/mongodb` |\n\nSQL is the third option for every store interface in that table —\n`KyselyChannelStore`, `KyselyWorkflowService`, `KyselySecretService` and friends\nlive in `@pikku/kysely` and are covered by `pikku-kysely`, because using them\nmeans writing queries.\n\nPer-package detail: `references/aws.md`, `references/backblaze.md`,\n`references/redis.md`, `references/mongodb.md`, `references/schema.md`.\n\n## What changes when you swap a backend\n\n### Redis and MongoDB are not interchangeable, in two ways\n\nThey cover almost the same interface list, but:\n\n- **MongoDB has AI conversation storage and Redis does not.**\n `MongoDBAgentStorageService` is the only implementation of\n `AgentStorageService`/`AgentRunStateService`. A Redis-only deployment cannot\n persist agent conversations.\n- **Every MongoDB service needs `await init()`; no Redis service does.** `init()`\n is what creates the collections and indexes. Constructing a\n `MongoDBChannelStore` and using it without awaiting `init()` compiles and then\n behaves like an unindexed collection — slow first, wrong later.\n\nRedis services take the connection directly (an ioredis `Redis`, `RedisOptions`,\nor a URL string). MongoDB services take a `Db`, which means a `PikkuMongoDB`\nwrapper has to be constructed and initialised before any of them.\n\n### The two content backends share a design and a trap\n\n`S3Content` and `B2Content` are close enough to swap, and both:\n\n- treat `bucket` on every call as a **logical** bucket stored as a path prefix\n (`${bucket}/${key}`) inside the one real bucket the config names. Do not\n provision a bucket per logical bucket — the config takes exactly one.\n- **ignore `visibility` on `getUploadURL`**.\n- **swallow write failures**: `writeFile`, `copyFile` and `deleteFile` log and\n return `false` rather than throwing, while the read paths throw. An ignored\n return value is a silently lost file.\n\nWhere they diverge:\n\n| | `S3Content` | `B2Content` |\n| --- | --- | --- |\n| Signing failure | **Fails open** — logs and returns the *unsigned* URL | Throws |\n| `writeFile` memory | Streams | **Buffers the whole stream** to compute a SHA-1 |\n| Client-side upload integrity | Presigned, expires at a fixed 3600s | `X-Bz-Content-Sha1: do_not_verify` — unverified |\n| Credential rotation | Picked up by the SDK provider chain | Auth is cached for the instance's lifetime — construct a new `B2Content` |\n\nThe S3 fail-open is the one to design around: on a private CloudFront\ndistribution the client gets a 403, and on a public one you have just handed out\nan unrestricted link. Validate `signConfig` at boot rather than trusting a throw.\n\n### Secret backends differ on whether the app can write\n\n- **`AWSSecrets` is read-only.** `setSecret` and `deleteSecret` throw. Secrets\n are managed out of band; the app only reads them.\n- **Redis and MongoDB do envelope encryption** and can write, delete, and\n `rotateKEK()`. Rotation requires `previousKey` to have been set — a service\n constructed without it cannot rotate later without a redeploy.\n- **Only MongoDB has audit hooks** (`audit`, `auditReads`).\n\n`AWSSecrets` also collapses every failure — missing, denied, binary-only — into\nthe same `FATAL: Error finding secret: <id>`, with the real reason on the error's\n`cause`. Read `cause` before concluding a secret is absent; `hasSecret` returns\n`false` for any error and cannot distinguish the two either.\n\n### AJV and cfworker are not drop-in equivalents\n\nSwapping them changes behaviour without changing types:\n\n- **`useDefaults`**: AJV fills schema defaults into the validated object in\n place. cfworker does not, so a field you relied on being defaulted arrives\n `undefined` on Workers.\n- **Recompilation**: AJV caches by name for the process lifetime — a second\n `compileSchema` with the same name is a no-op. cfworker replaces the validator\n when the schema value changes, which is what lets a dev hot-reload pick up\n regenerated schemas. On AJV, restart the process instead.\n- **Coercion is neither one's job.** `coerceTypes` is off; a query-string `\"1\"`\n becomes `1` in the wiring layer, not here.\n\nBoth throw `UnprocessableContentError` (422) on a failed validation, and both\nthrow a **bare string** — `Missing validator for <name>` — for a *missing*\nschema. It is not an `Error`, so `catch (e) { e.message }` reads `undefined`.\nThat almost always means codegen did not run.\n\nUse cfworker on Cloudflare Workers: AJV compiles with `new Function`, which the\nWorkers runtime forbids.\n\n### SQS gives you no result back\n\n`SQSQueueService` sets `supportsResults = false` and `getJob()` always throws —\nthe transport is fire-and-forget. So is the Azure Storage Queue backend. Reach\nfor BullMQ or PgBoss (see `pikku-wiring`) when a caller needs the job's result.\n\n## What NOT to do\n\n- Do not ignore the boolean from `writeFile`, `copyFile` or `deleteFile`. Both\n content backends report failure that way and neither throws.\n- Do not rely on `S3Content.signURL` throwing. It fails open and hands back an\n unsigned URL.\n- Do not construct a MongoDB-backed service without awaiting `init()`.\n- Do not assume AJV and cfworker validate identically — `useDefaults` alone\n changes what your function receives.\n- Do not provision one real bucket per logical bucket; the prefix is the bucket.\n- Do not reach for SQS when a caller needs the result of the job.\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(\n 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```\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({\n type: 'user.deleted',\n source: 'explicit',\n metadata: { userId },\n })\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/references/audit.md": "# Pikku Audit\n\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(\n 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\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\n .selectFrom('invoice') /* ... */\n .executeTakeFirstOrThrow()\n await kysely\n .updateTable('invoice')\n .set({ status: 'cancelled' }) /* ... */\n .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 {\n auditLog,\n kysely: createAuditedKysely(services.kysely, { audit: auditLog }),\n }\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?\n wireType?\n wireId?\n traceId?\n transactionId?\n 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-services/references/config.md": "# Pikku Config, Secrets & OAuth2\n\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(\n (await secrets.getSecret('STRIPE_CONFIG')).reveal()\n ),\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 `references/services.md`).\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-services/references/pino.md": "# Pikku Pino (Structured Logging)\n\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-services/references/services.md": "# Pikku Services (Dependency Injection)\n\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/setup'\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/setup'\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 &\n SingletonServices\nexport type WiredServices = SecretlessServices<\n RequiredSingletonServices & Services\n>\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/setup'\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) {\n return this.todos.get(id)\n }\n async list() {\n return [...this.todos.values()]\n }\n async delete(id: string) {\n this.todos.delete(id)\n }\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-services/SKILL.md": "---\nname: pikku-services\ndescription: >-\n Use for the service layer of a Pikku app — dependency injection with pikkuServices and\n pikkuWireServices, startup/shutdown work with pikkuServerLifecycle, configuration through the\n secrets, variables and credentials services, the audit sink and buffer, and structured logging.\n TRIGGER when: code uses pikkuServices/pikkuWireServices/pikkuServerLifecycle, defineSecret,\n defineVariable or defineCredential, user asks about services.ts, lifecycle.ts, dependency\n injection, env vars, secrets, API credentials, audit logging, AuditService, PinoLogger, or a\n built-in service. DO NOT TRIGGER when: user asks about middleware (use pikku-middleware),\n authentication or permissions (use pikku-auth), or a third-party backend such as Redis, S3 or\n MongoDB (use pikku-service-backends).\ninstallGroups: [core]\n---\n\n# Pikku Services\n\nSignatures and option keys come from `pikku doc` — run `pikku doc --ai` for the\ninstalled surface. This skill is the part the compiler cannot tell you: where a\nvalue is allowed to be read, and what a service's lifetime commits you to.\n\n## Two lifetimes, and that is the whole model\n\n**Singleton services** (`pikkuServices`) are built once at startup and live for\nthe process. **Wire services** (`pikkuWireServices`) are built fresh per HTTP\nrequest, queue job, channel message or CLI command, and receive the wire.\n\nEverything else follows from that split: a database pool is a singleton, a\nrequest-scoped logger or audit buffer is a wire service, and startup work that\nneeds the singletons goes in `pikkuServerLifecycle` rather than in a module's\ntop level.\n\n## Pick the reference\n\n| You are… | Read |\n| ---------------------------------------------------------------------------- | ------------------------------------------------------------ |\n| Writing or wiring `services.ts` / `lifecycle.ts`, or adding a custom service | `references/services.md` |\n| Reading config — secrets, env vars, or a per-user API credential | `references/config.md` |\n| Recording audit events, or choosing a sink | `references/audit.md` and `references/audit-wire-service.md` |\n| Setting up structured logging | `references/pino.md` |\n| Reaching for Redis, S3, SQS, MongoDB or a schema backend | `pikku-service-backends` |\n| Sending outgoing webhooks to a customer's endpoint | `pikku-webhook` |\n\n## Where a value may be read\n\n- **Never `process.env` inside a Pikku function.** Use `services.variables.get()`\n or `services.secrets`. `process.env` belongs to server bootstrap (`start.ts`)\n — and under `pikku dev` / `pikku serve` there is no `start.ts` at all, so\n startup work goes in a `pikkuServerLifecycle` hook, which receives the\n singletons and reads through `variables` too.\n- **`secrets` never reaches a function.** `WiredServices` is wrapped in\n `SecretlessServices<…>`, so it is stripped at the type level rather than merely\n omitted by convention. Read a secret in a service factory or middleware and\n hand the resulting service the value.\n\n## What NOT to do\n\n- **Do not guard a service's existence in a function body.** `if (!db) throw …`\n is dead code. Optionality lives only in the `SingletonServices` declaration and\n means \"may not be created\" — a service is optional precisely because nothing\n destructures it. The inspector records every service destructured by a wired\n `func`, `permissions` or `middleware` and marks it required, so inside the\n function it is a non-optional value. A genuinely conditional integration is a\n configuration concern: branch in `services.ts` or fail fast at startup.\n- **Do not take `services` as a named parameter and cast in the body.** The\n inspector reads the destructuring pattern to build the manifest; a cast makes\n the service invisible to it, so tree-shaking drops what the function needs.\n- **Do not hand-roll an audit table.** `auditLog.write()` on an `audit: true`\n function enriches the event with the function id, wire type, trace id and user\n identity; an `insertInto('audit_log')` of your own gets none of that, and a\n write inside a transaction that later rolls back records nothing.\n- **Do not log an unrevealed secret.** Every logger argument is `Safe<>`-guarded,\n so a `SecretValue` nested anywhere in the call collapses it to `never` and it\n stops compiling. That is deliberate — it would have printed `[secret]` anyway.\n", "pikku-software-archaeology/example/second-opinion-sample-report.md": "# Your app, in plain English — and where it could get better\n\n_A second opinion on the competitor-tracking system_\n\n> Worked example for the pikku-software-archaeology 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\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\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-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│ ├── second-opinion.md # blueprint → owner-facing report: method, voice, red flags\n│ └── second-opinion-report-template.md # the layered structure to fill in\n├── example/\n│ └── second-opinion-sample-report.md # worked example (competitor-tracking area, founder voice)\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\": {\n \"type\": \"string\",\n \"description\": \"what this location shows\"\n }\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\": [\n \"name\",\n \"purpose\",\n \"actors\",\n \"capabilities\",\n \"terminology\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"purpose\": {\n \"type\": \"string\",\n \"description\": \"1-3 sentences: what problem, for whom\"\n },\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\": {\n \"type\": \"string\",\n \"enum\": [\"human\", \"system\", \"external\"]\n },\n \"description\": { \"type\": \"string\" }\n }\n }\n },\n \"capabilities\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" }\n },\n \"terminology\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"term\", \"meaning\"],\n \"properties\": {\n \"term\": { \"type\": \"string\" },\n \"meaning\": { \"type\": \"string\" }\n }\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\": [\n \"name\",\n \"description\",\n \"entities\",\n \"commands\",\n \"queries\",\n \"events\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"entities\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"commands\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"queries\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"events\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"policies\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": \"policy names from policies.json owned by this domain\"\n },\n \"sourcePaths\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"where this domain currently lives (usually scattered)\"\n },\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\": [\n \"name\",\n \"domain\",\n \"description\",\n \"attributes\",\n \"evidence\",\n \"confidence\"\n ],\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"belongs-to\",\n \"has-many\",\n \"has-one\",\n \"references\",\n \"many-to-many\"\n ]\n },\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\": {\n \"type\": \"string\",\n \"description\": \"command or event name that causes it\"\n }\n }\n }\n },\n \"ownership\": {\n \"type\": \"string\",\n \"description\": \"which actor/tenant owns rows of this entity\"\n },\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\": [\n \"name\",\n \"domain\",\n \"actor\",\n \"input\",\n \"preconditions\",\n \"effects\",\n \"eventsProduced\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/$defs/conceptName\",\n \"description\": \"imperative VerbNoun: ApproveInvoice\"\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": {\n \"type\": \"string\",\n \"description\": \"how it is invoked today (route, job, webhook, cron)\"\n },\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\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"effects\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" }\n },\n \"eventsProduced\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"policies\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"policy names from policies.json enforced here\"\n },\n \"currentImplementation\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"file paths implementing it today\"\n },\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\": [\n \"name\",\n \"domain\",\n \"actor\",\n \"description\",\n \"returns\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/$defs/conceptName\",\n \"description\": \"GetX / ListX / SearchX\"\n },\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\": {\n \"name\": { \"type\": \"string\" },\n \"type\": { \"type\": \"string\" },\n \"required\": { \"type\": \"boolean\" }\n }\n }\n },\n \"returns\": { \"type\": \"string\" },\n \"scoping\": {\n \"type\": \"string\",\n \"description\": \"tenancy/visibility rule applied (e.g. 'rows owned by caller only')\"\n },\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\": [\n \"name\",\n \"domain\",\n \"description\",\n \"producedBy\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/$defs/conceptName\",\n \"description\": \"past-tense business fact: InvoicePaid. No technical events (ButtonClicked).\"\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"description\": { \"type\": \"string\" },\n \"producedBy\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" },\n \"description\": \"command/workflow names\"\n },\n \"consumedBy\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"what reacts today (email send, status flip, webhook out)\"\n },\n \"payloadHints\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"explicit\": {\n \"type\": \"boolean\",\n \"description\": \"true if the code emits a real event; false if reconstructed from inline side-effects\"\n },\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\": [\n \"name\",\n \"rule\",\n \"type\",\n \"domain\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/conceptName\" },\n \"rule\": {\n \"type\": \"string\",\n \"description\": \"plain-language statement: 'Only the invoice owner can send it'\"\n },\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"authorization\",\n \"validation\",\n \"state\",\n \"business-constraint\",\n \"rate-limit\"\n ]\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedAt\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"every code location enforcing it — multiple locations = divergence risk, note in gaps.json\"\n },\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\": [\n \"name\",\n \"kind\",\n \"actor\",\n \"trigger\",\n \"steps\",\n \"result\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"user\", \"admin\", \"system\"] },\n \"actor\": { \"type\": \"string\" },\n \"trigger\": { \"type\": \"string\" },\n \"steps\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" }\n },\n \"result\": { \"type\": \"string\" },\n \"commandsInvolved\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"eventsInvolved\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"schedule\": {\n \"type\": \"string\",\n \"description\": \"cron expression for system workflows, if any\"\n },\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\": {\n \"type\": \"string\",\n \"description\": \"test file path + test name, when sourced from a test\"\n }\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"rest\",\n \"graphql\",\n \"rpc\",\n \"webhook-in\",\n \"webhook-out\",\n \"page\",\n \"cli\",\n \"websocket\",\n \"sse\"\n ]\n },\n \"method\": { \"type\": \"string\" },\n \"path\": { \"type\": \"string\" },\n \"auth\": {\n \"type\": \"string\",\n \"description\": \"none | session | jwt | api-key | signature | capability-url | ...\"\n },\n \"mapsTo\": {\n \"type\": \"object\",\n \"required\": [\"type\", \"name\"],\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\"command\", \"query\", \"event-ingress\"],\n \"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 },\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\": [\n \"name\",\n \"category\",\n \"purpose\",\n \"direction\",\n \"importance\",\n \"replacementDifficulty\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"category\": {\n \"type\": \"string\",\n \"description\": \"payments | email | auth | storage | analytics | llm | sms | ...\"\n },\n \"purpose\": { \"type\": \"string\" },\n \"dataExchanged\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"direction\": {\n \"type\": \"string\",\n \"enum\": [\"outbound\", \"inbound\", \"both\"]\n },\n \"importance\": {\n \"type\": \"string\",\n \"enum\": [\"critical\", \"important\", \"peripheral\"]\n },\n \"replacementDifficulty\": {\n \"type\": \"string\",\n \"enum\": [\"trivial\", \"moderate\", \"hard\"]\n },\n \"configVia\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"env vars / secrets used\"\n },\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"frontend\",\n \"api\",\n \"worker\",\n \"job\",\n \"webhook-handler\",\n \"cli\",\n \"service\",\n \"proxy\",\n \"other\"\n ]\n },\n \"responsibility\": { \"type\": \"string\" },\n \"dependsOn\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"runtime\": {\n \"type\": \"string\",\n \"description\": \"how it runs today (process, cron, lambda, ...)\"\n },\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\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"deployment constraints worth preserving (ports, raw-body routes, ordering)\"\n }\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\": [\n \"statement\",\n \"domain\",\n \"enforcedBy\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"statement\": {\n \"type\": \"string\",\n \"description\": \"must ALWAYS hold: 'Invoice numbers are sequential per user with no gaps'\"\n },\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"enforcedBy\": {\n \"type\": \"string\",\n \"description\": \"db-constraint | code-guard | convention | nothing (!)\"\n },\n \"atRiskBecause\": {\n \"type\": \"string\",\n \"description\": \"why current enforcement is fragile, if it is\"\n },\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\": [\n \"problem\",\n \"kind\",\n \"impact\",\n \"recommendation\",\n \"evidence\"\n ],\n \"properties\": {\n \"problem\": { \"type\": \"string\" },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"incomplete-feature\",\n \"todo\",\n \"duplication\",\n \"bug\",\n \"hack\",\n \"dead-code\",\n \"unclear-ownership\",\n \"architecture\",\n \"security\",\n \"open-product-decision\"\n ]\n },\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\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"type\": \"string\" },\n \"description\": \"file paths in the existing repo\"\n },\n \"future\": {\n \"type\": \"object\",\n \"required\": [\"domain\"],\n \"properties\": {\n \"domain\": { \"$ref\": \"#/$defs/conceptName\" },\n \"concepts\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"commands/queries/events/entities this code becomes\"\n }\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\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"file path; for sub-file drops use 'path (symbolName)' when part of a file survives\"\n },\n \"reason\": { \"type\": \"string\" }\n }\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\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n }\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\": [\n \"name\",\n \"kind\",\n \"audience\",\n \"purpose\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"web-ui\",\n \"cli\",\n \"mcp\",\n \"openapi-rest\",\n \"graphql\",\n \"sdk\",\n \"websocket-realtime\",\n \"webhook-in\",\n \"webhook-out\",\n \"email\",\n \"other\"\n ]\n },\n \"audience\": {\n \"type\": \"string\",\n \"enum\": [\n \"human\",\n \"developer\",\n \"agent\",\n \"external-system\",\n \"internal\"\n ]\n },\n \"purpose\": { \"type\": \"string\" },\n \"surfaceCount\": {\n \"type\": \"number\",\n \"description\": \"how many ops/tools/commands/routes this channel exposes\"\n },\n \"generated\": {\n \"type\": \"boolean\",\n \"description\": \"true if generated from another source (OpenAPI from routes, typed SDK, MCP tools from funcs) rather than hand-written\"\n },\n \"domainsServed\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/conceptName\" }\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"complete\", \"partial\", \"stub\", \"deprecated\"]\n },\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\": [\n \"framework\",\n \"styling\",\n \"dataLayer\",\n \"auth\",\n \"evidence\",\n \"confidence\"\n ],\n \"properties\": {\n \"framework\": {\n \"type\": \"string\",\n \"description\": \"e.g. TanStack Start, Next.js, Remix, Vite SPA\"\n },\n \"rendering\": {\n \"type\": \"string\",\n \"enum\": [\"spa\", \"ssr\", \"ssg\", \"streaming-ssr\", \"mixed\"]\n },\n \"router\": {\n \"type\": \"string\",\n \"description\": \"e.g. TanStack Router (file-based), React Router\"\n },\n \"styling\": {\n \"type\": \"string\",\n \"description\": \"the design system, e.g. Mantine, Tailwind, MUI, CSS modules\"\n },\n \"designSystemConsistency\": {\n \"type\": \"string\",\n \"enum\": [\"single-system\", \"mostly-consistent\", \"mixed\", \"ad-hoc\"],\n \"description\": \"how uniformly ONE component/theme system is used — the rebuild target is 'everything Mantine', so divergence is porting work\"\n },\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\": [\n \"pattern\",\n \"observation\",\n \"recommendation\",\n \"evidence\"\n ],\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\": {\n \"type\": \"string\",\n \"description\": \"what is inconsistent, with concrete examples (e.g. 'Add-site uses a Drawer, Edit-site uses a Modal for the same task')\"\n },\n \"impact\": {\n \"type\": \"string\",\n \"description\": \"what it does to the user (feels unpolished/confusing) or to maintenance (a color change means hunting every file)\"\n },\n \"recommendation\": {\n \"type\": \"string\",\n \"description\": \"the fix — usually standardize on one pattern, move values to theme tokens, or extract one shared component\"\n },\n \"severity\": {\n \"type\": \"string\",\n \"enum\": [\"minor\", \"worth-fixing\", \"serious\"]\n },\n \"evidence\": { \"$ref\": \"#/$defs/evidence\" },\n \"confidence\": { \"$ref\": \"#/$defs/confidence\" }\n }\n }\n },\n \"stateManagement\": { \"type\": \"string\" },\n \"dataLayer\": {\n \"type\": \"string\",\n \"description\": \"how the UI talks to the backend: e.g. pikku-react hooks, REST fetch helpers, tRPC, GraphQL client\"\n },\n \"auth\": {\n \"type\": \"string\",\n \"description\": \"client auth mechanism: e.g. better-auth, next-auth, custom JWT\"\n },\n \"buildTool\": { \"type\": \"string\" },\n \"i18n\": {\n \"type\": \"string\",\n \"description\": \"internationalization approach, or 'none'\"\n },\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\": {\n \"type\": \"string\",\n \"description\": \"URL path, e.g. /app/sites/:id\"\n },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"page\",\n \"layout\",\n \"index\",\n \"modal-or-drawer\",\n \"redirect\"\n ]\n },\n \"purpose\": {\n \"type\": \"string\",\n \"description\": \"what the user does/sees here, in product terms\"\n },\n \"auth\": {\n \"type\": \"string\",\n \"description\": \"none | authenticated | admin | role:xyz\"\n },\n \"dataFrom\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"query/command names (from queries.json/commands.json) this route reads/calls\"\n },\n \"usesComponents\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"component names from frontend-components.json\"\n },\n \"userFlows\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"workflows.json (kind=user) names this route participates in\"\n },\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\": {\n \"type\": \"string\",\n \"enum\": [\n \"layout\",\n \"navigation\",\n \"presentational\",\n \"feature\",\n \"form\",\n \"data-display\",\n \"chart\",\n \"table\",\n \"overlay\",\n \"provider\",\n \"other\"\n ]\n },\n \"purpose\": { \"type\": \"string\" },\n \"reuse\": {\n \"type\": \"string\",\n \"enum\": [\"shared\", \"one-off\"],\n \"description\": \"used across features vs single-use\"\n },\n \"rebuild\": {\n \"type\": \"string\",\n \"enum\": [\n \"mantine-standard\",\n \"mantine-composition\",\n \"custom-style\",\n \"custom-logic\"\n ],\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\": {\n \"type\": \"string\",\n \"description\": \"REQUIRED when rebuild=custom-logic: what the bespoke behavior is and why a stock component can't replace it\"\n },\n \"dependencies\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"notable libs it pulls in (charting/table/editor) — replacement considerations for the port\"\n },\n \"usedBy\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"routes/components that use it\"\n },\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-build`) 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-wiring` EventHub topics / channels |\n| `frontend.json` | `apps/app` (TanStack Start) shell: router, `@pikku/mantine` theme, `pikku-react` 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/references/second-opinion-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\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\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\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-software-archaeology/references/second-opinion.md": "# 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 the extraction phase (see SKILL.md). If none exists, run that 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\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/second-opinion-sample-report.md`):\n\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\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\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\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\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\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\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\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**:\n\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 the extraction phase\n\nExtraction = facts → `.knowledge/` blueprint, for a machine to rebuild from. This report = blueprint → opinionated argument, for a human to decide from. One extracts; one advises. Run the extraction first (or point this at an existing `.knowledge/`), then translate its `gaps.json` + `invariants.json` + `migration.json` into the business-language report above.\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(\n readFileSync(join(here, '..', 'references', 'blueprint.schema.json'), 'utf8')\n)\n\nconst dir = process.argv[2]\nif (!dir) {\n console.error('usage: node validate.mjs <.knowledge dir>')\n process.exit(2)\n}\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)\n schema = { ...resolveRef(schema.$ref), ...schema, $ref: undefined }\n if (schema.enum && !schema.enum.includes(value)) {\n errors.push(\n `${path}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}`\n )\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`)\n return\n }\n for (const req of schema.required || []) {\n if (!(req in value))\n 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)) {\n errors.push(`${path}: expected array`)\n return\n }\n if (schema.minItems && value.length < schema.minItems) {\n errors.push(\n `${path}: needs at least ${schema.minItems} item(s), has ${value.length}`\n )\n }\n if (schema.items)\n value.forEach((v, i) => check(v, schema.items, `${path}[${i}]`))\n } else if (t === 'string') {\n if (typeof value !== 'string') {\n errors.push(`${path}: expected string`)\n return\n }\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})`)\n 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(\n (docs['domains.json'].domains || []).map((d) => d.name)\n )\n const commandNames = new Set(\n (docs['commands.json'].commands || []).map((c) => c.name)\n )\n const queryNames = new Set(\n (docs['queries.json']?.queries || []).map((q) => q.name)\n )\n const eventNames = new Set(\n (docs['events.json']?.events || []).map((e) => e.name)\n )\n\n const wantDomain = (owner, d) => {\n if (d && !domains.has(d))\n 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))\n warnings.push(\n `commands.json:${c.name} produces \"${ev}\" which is not in events.json`\n )\n }\n }\n for (const q of docs['queries.json']?.queries || [])\n wantDomain(`queries.json:${q.name}`, q.domain)\n for (const e of docs['entities.json']?.entities || [])\n wantDomain(`entities.json:${e.name}`, e.domain)\n for (const ev of docs['events.json']?.events || [])\n 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))\n errors.push(\n `api.json:${s.method || ''} ${s.path}: maps to unknown command \"${name}\"`\n )\n if (type === 'query' && !queryNames.has(name))\n errors.push(\n `api.json:${s.method || ''} ${s.path}: maps to unknown query \"${name}\"`\n )\n if (type === 'event-ingress' && !eventNames.has(name))\n errors.push(\n `api.json:${s.method || ''} ${s.path}: event-ingress maps to unknown event \"${name}\" (state-changing webhooks should map to a command instead)`\n )\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 || [])\n if (!commandNames.has(c))\n warnings.push(\n `domains.json:${d.name}: lists command \"${c}\" not in commands.json`\n )\n for (const q of d.queries || [])\n if (!queryNames.has(q))\n warnings.push(\n `domains.json:${d.name}: lists query \"${q}\" not in queries.json`\n )\n for (const e of d.events || [])\n if (!eventNames.has(e))\n warnings.push(\n `domains.json:${d.name}: lists event \"${e}\" not in events.json`\n )\n const policyNames = new Set(\n (docs['policies.json']?.policies || []).map((p) => p.name)\n )\n for (const p of d.policies || [])\n if (!policyNames.has(p))\n warnings.push(\n `domains.json:${d.name}: lists policy \"${p}\" not in policies.json`\n )\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(\n `commands.json:${c.name}: no policies or preconditions — really unguarded, or missed extraction?`\n )\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(\n `frontend-routes.json:${r.path}: uses component \"${c}\" not in frontend-components.json`\n )\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(\n `frontend-components.json:${c.name}: rebuild=custom-logic but no customLogic description — port risk is unactionable`\n )\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(\n `frontend-routes.json:${r.path}: reads \"${d}\" which is not a known query/command`\n )\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 (\n (consistency === 'mixed' || consistency === 'ad-hoc') &&\n findingCount === 0\n ) {\n warnings.push(\n `frontend.json: designSystemConsistency=\"${consistency}\" but designFindings is empty — name the specific broken patterns (interaction/theming/cross-page/…)`\n )\n }\n}\n\nfor (const w of warnings) console.log(`WARN ${w}`)\nfor (const e of errors) console.log(`ERROR ${e}`)\nconsole.log(\n `\\n${errors.length} error(s), ${warnings.length} warning(s) across ${Object.keys(docs).length} files`\n)\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) — and when turning that blueprint into a plain-language second opinion for the non-technical owner who holds the 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\", 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, or asks \"explain how my app works\" / \"what would you do differently\" / \"is this built well?\" for a founder, PM or operator audience. DO NOT TRIGGER for: documenting code structure, generating API docs from an already-clean codebase, or an engineer-facing code review.'\ninstallGroups: [core]\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\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\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\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\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 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\n## The second phase — a report the owner can act on\n\nExtraction produces a blueprint for a machine to rebuild from. The same\n`.knowledge/` directory is also the input to a second opinion for the person who\nholds the app: what works, what is holding them back, and what you would build\ninstead, argued in business outcomes rather than architecture.\n\nThat phase has its own voice rules, structure and red flags — read\n`references/second-opinion.md`, fill in\n`references/second-opinion-report-template.md`, and match the tone of\n`example/second-opinion-sample-report.md`. It consumes the blueprint; it never\nre-derives facts from the code, so run the extraction first.\n", "pikku-webhook/SKILL.md": "---\nname: pikku-webhook\ndescription: >-\n Use when an application needs to SEND outgoing webhooks — notifying a customer's endpoint that\n something happened, with signing, retries and a delivery log. Covers WebhookService,\n QueueWebhookService, the `pikku-outgoing-webhooks` queue worker, `scaffold.webhook`,\n `config.webhook`, signature verification on the receiving side, and KyselyWebhookService's\n delivery history. TRIGGER when: code uses webhookService, QueueWebhookService,\n KyselyWebhookService, pikkuWebhookWorkerFunc, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,\n SendWebhookInput or X-Pikku-Signature. TRIGGER when: the user asks to emit events to a\n customer URL, build a webhook endpoint settings screen, add a signing secret, or show\n delivery attempts. DO NOT TRIGGER when: the user is RECEIVING webhooks from a third party\n into a route — that is an ordinary wireHTTP function (use pikku-wiring).\ninstallGroups: [core]\n---\n\n# Pikku Outgoing Webhooks\n\nPikku ships an outgoing webhook primitive, so an application never hand-rolls\n`fetch` + HMAC + retries. `WebhookService.send()` signs the body, enqueues a\ndelivery job, and the generated `pikku-outgoing-webhooks` queue worker POSTs it;\na non-2xx throws, so the queue retries with backoff. Swapping the queue-only\ndefault for `KyselyWebhookService` adds a durable delivery + attempt history\nwith no change to call sites.\n\n**Do not write a bespoke `fetch(url, { headers: { 'x-my-signature': … } })` for\nan outgoing event.** If the shipped signing scheme or delivery model genuinely\ndoes not fit, subclass `WebhookService` — it is an abstract class precisely so\nan app can substitute its own transport (direct send, Svix) and keep the same\ncall sites and delivery history.\n\n## Agent Operating Procedure\n\n1. Turn the feature on: `\"scaffold\": { \"webhook\": true }` in `pikku.config.json`.\n2. Run `pikku all`. It writes `<scaffold.pikkuDir>/webhook/webhook.gen.ts` (the\n queue worker) and `webhook.schemas.gen.ts`. Never hand-edit either.\n3. Register a `webhookService` singleton in `createSingletonServices`. Without\n it `services.webhookService` is `undefined` and nothing sends.\n4. Make sure a queue backend is wired. The worker is an ordinary\n `wireQueueWorker` — with no `queueService`, `send()` throws.\n5. Call `webhookService.send(...)` from a function body. Never call `fetch`\n directly for an outgoing event.\n6. Validate with `pikku all` and the project's typecheck.\n\n## Config\n\n```jsonc\n// pikku.config.json\n{\n \"scaffold\": {\n \"pikkuDir\": \"src/pikku\",\n \"webhook\": true, // on or off — it exposes no endpoint and has no path override\n },\n}\n```\n\n`scaffold.webhook` is a plain boolean, unlike the other scaffold flags which\naccept `{ path }`. The two generated paths are derived\n(`webhookWorkersFile`, `webhookSchemasFile`) and can be set explicitly in\n`pikku.config.json` if a project needs them somewhere else.\n\nRuntime defaults live on `CoreConfig.webhook`:\n\n```ts\nexport interface Config extends CoreConfig {}\n\nconst config: Config = {\n webhook: {\n secret: 'WEBHOOK_SIGNING_KEY', // a secret NAME, resolved via services.secrets\n signatureHeader: 'X-Pikku-Signature', // default\n retries: 3, // default; attempts = retries + 1\n retryDelay: '30s', // omit for exponential backoff\n allowedHosts: ['hooks.example.com'], // SSRF allowlist\n },\n}\n```\n\n`allowedHosts` set means _only_ those hostnames are deliverable. Omitted, every\npublic host is allowed and private/internal ranges are blocked — loopback,\nRFC1918, link-local `169.254.0.0/16` (cloud metadata), CGNAT `100.64.0.0/10`\n(Alibaba's metadata endpoint), multicast and the TEST-NETs. A URL is\nuser-supplied data; do not bypass `safeFetch` by delivering yourself.\n\n## Sending\n\n```ts\nimport { pikkuFunc } from '#pikku/function'\n\nexport const notifyOrderShipped = pikkuFunc({\n func: async ({ webhookService }, { endpointUrl, orderId, secret }) => {\n const { jobId } = await webhookService.send({\n url: endpointUrl,\n event: 'order.shipped',\n data: { orderId, shippedAt: new Date().toISOString() },\n secret, // per-endpoint raw key; overrides config.webhook.secret\n organizationId: orgId, // persisted by store-backed services only\n })\n return { jobId }\n },\n})\n```\n\n`send()` returns as soon as the job is enqueued — it is **not** a delivery\nreceipt. `jobId` is the queue job; with `KyselyWebhookService` it is also the\n`deliveryId`, so it is stable across retries and is what a UI polls.\n\nThe two `secret` fields are deliberately different and are the most common\nmistake:\n\n| Where | Meaning |\n| ------------------------- | ------------------------------------------------------------------- |\n| `config.webhook.secret` | a secret **name**, read through `services.secrets` at enqueue time |\n| `SendWebhookInput.secret` | a **raw HMAC key**, for per-endpoint secrets held in your own table |\n\nThe raw key never enters the queue payload: the body is signed at enqueue time\nand only the resulting header travels with the job. That is also why the body\nis serialized once — a retry re-POSTs identical bytes, so the signature stays\nvalid.\n\nWith neither secret set, deliveries go **unsigned**. A missing named secret is\nlogged as an error and still sends unsigned; treat that log line as a\nmisconfiguration, not noise.\n\n## What this does not give you\n\nThere is no subscription model. `webhookSchema` owns exactly two tables —\n`webhookDelivery` and `webhookDeliveryAttempt` — and both are delivery-side\nhistory. Nothing stores _which_ URL belongs to which customer, which events\nthey asked for, or whether their endpoint is still enabled.\n\nThat is the app's table, and every app that exposes webhooks to its users\nneeds one:\n\n| Column | Why |\n| --------- | ------------------------------------------------------------------------------------------------------------- |\n| `url` | where to POST |\n| `secret` | the raw HMAC key, passed as `SendWebhookInput.secret` |\n| `events` | which event names this endpoint subscribed to |\n| `enabled` | so a failing endpoint can be paused without deleting it |\n| scope | the org/tenant column you filter on — mirror it into `organizationId` so the delivery log scopes the same way |\n\nSo one emitted event becomes a `SELECT` over your endpoint table and one\n`send()` per row. Everything after that call — signing, queueing, retrying,\nrecording — is the primitive's.\n\nThe single-integration case needs none of this: one fixed URL on the row it\nbelongs to, and `send()` straight at it.\n\n## Verifying on the receiving side\n\n`sign()` produces `sha256=<hex>` (GitHub style, body only, no timestamp) into\n`X-Pikku-Signature`, and `X-Pikku-Event` carries the event name. `verify()` is\npublic because receivers share the scheme:\n\n```ts\nconst raw = await request.text()\nif (\n !webhookService.verify(secret, request.headers.get('x-pikku-signature')!, raw)\n) {\n throw new UnauthorizedError()\n}\n```\n\nIt compares in constant time via `timingSafeStringEqual`. Never compare\nsignatures with `===`, and verify against the **raw body text**, not a\nre-serialized parsed object.\n\n## Delivery history\n\nThe default `QueueWebhookService` keeps no history: `listDeliveries`,\n`getDelivery` and `recordAttempt` throw `NotImplementedError`. Register\n`KyselyWebhookService` from `@pikku/kysely` to get them.\n\n```ts\nimport { KyselyWebhookService } from '@pikku/kysely'\n\nconst webhookService = new KyselyWebhookService(queueService, kysely)\nawait webhookService.init() // creates webhookDelivery + webhookDeliveryAttempt\n```\n\n`init()` is idempotent and creates the tables through the pikku schema\nbootstrap. Do not write your own migration for these tables.\n\n- `webhookDelivery` — one row per `send()`: `deliveryId`, `organizationId`,\n `url`, `event`, `status` (`pending` | `delivered` | `failed`), `attempts`,\n `createdAt`, `updatedAt`, `deliveredAt`.\n- `webhookDeliveryAttempt` — one row per try: `attemptNumber`, `statusCode`,\n `responseBody` (failures only, truncated to 2000 chars), `error`.\n\nRead them through the service, not with your own query:\n\n```ts\nconst deliveries = await webhookService.listDeliveries({\n organizationId,\n limit: 25,\n})\nconst detail = await webhookService.getDelivery(deliveryId) // { delivery, attempts }\n```\n\nBuilding a console screen is `listDeliveries` for the list and `getDelivery` for\nthe drill-in. A hand-written select over `webhookDelivery` is a sign the wrong\nservice is registered.\n\n## What the worker does\n\nThe generated worker is a thin wrapper over `pikkuWebhookWorkerFunc`. It POSTs\nthrough `safeFetch` with a 30s timeout, treats 2xx as delivered, captures the\nresponse body on failure, records the attempt when a `deliveryId` is present,\nand **throws** on failure so the queue retries. Attempt recording is\nbest-effort: a store error is logged and does not fail the delivery. Retry\nexhaustion is logged by the queue runner — there is no `onFailure` hook.\n\n## Gotchas\n\n- `webhookService` is optional on `CoreSingletonServices`, but do **not** guard\n it in a function body — destructuring it marks it required (see\n `pikku-services`). Register it in `services.ts` or fail fast at startup.\n- The queue name is `pikku-outgoing-webhooks`, not `pikku-webhooks`.\n- `retries: 0` means one attempt and no backoff, not \"retry forever\".\n- The gateway's inbound `webhook` transport type is a different feature. This\n skill is outbound only.\n- Signing is body-only with no timestamp, so it does not defend against replay\n on its own. If a receiver needs replay protection, put a nonce or timestamp\n **inside** the payload, where it is covered by the signature.\n", "pikku-wiring/references/channel.md": "# Pikku WebSocket Wiring\n\n## API Reference\n\n### `wireChannel(config)`\n\n```typescript\nimport { wireChannel } from '#pikku/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-auth`).\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: {\n // ← 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-wiring/references/cli-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/cli'\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-wiring/references/cli.md": "# Pikku CLI Wiring\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/cli'\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/cli'\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/cli'\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 `cli-complete-example.md`.\n", "pikku-wiring/references/gateway-slack.md": "# Pikku Gateway Slack\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-wiring/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-wiring/references/http.md": "# Pikku HTTP Wiring\n\n## API Reference\n\nAll three come from `#pikku/http` (the generated `.pikku/http/index.ts`), 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 `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-auth`), 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-auth`), 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/function'\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/middleware'\nimport { cors, authBearer } from '@pikku/core/middleware'\n\naddHTTPMiddleware('*', [cors(), authBearer()])\n```\n", "pikku-wiring/references/mcp.md": "# Pikku MCP Wiring\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/mcp'\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/mcp'\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/mcp'\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/mcp'\n\nexport const planDayPrompt = pikkuMCPPromptFunc({\n input: UserIdInputSchema,\n func: async (_services, { userId }, { rpc }) => {\n const { todos } = await rpc.invoke('listTodos', {\n userId,\n completed: false,\n })\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-wiring/references/queue.md": "# Pikku Queue Wiring\n\n## API Reference\n\n### `wireQueueWorker(config)`\n\n```typescript\nimport { wireQueueWorker } from '#pikku/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-wiring/references/realtime-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-wiring/references/realtime.md": "# Pikku Realtime\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\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?: {\n reconnect?: boolean\n reconnectDelayMs?: number\n reconnectMaxDelayMs?: number\n })\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>(\n topic: K,\n handler: (data: EventHubTopics[K]) => void\n ): () => void\n unsubscribe<K extends keyof EventHubTopics>(\n topic: K,\n handler?: (data: EventHubTopics[K]) => void\n ): void\n\n // SSE at GET /events/:topic — one EventSource per topic\n subscribeToTopic<K extends keyof EventHubTopics>(\n topic: K,\n handler: (data: EventHubTopics[K]) => void\n ): { close: () => void }\n\n // generic escape hatches — see realtime-other-routes.md\n subscribeToSSE<T>(\n path: string,\n handler: (data: T) => void\n ): { close: () => void }\n connectToChannel(\n channelRoute: string,\n protocols?: string | string[]\n ): 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/function'\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')\n .values(data)\n .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>,\n topic: K,\n 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}>\n <App />\n </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 )\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 (\n <ul>\n {todos.map((t) => (\n <li key={t.id}>{t.title}</li>\n ))}\n </ul>\n )\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[realtime-other-routes.md](realtime-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-wiring/references/rpc.md": "# Pikku RPC Wiring\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-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\n```\n\nThe flag says the endpoint exists, not who may call it — each exposed function\nis gated by its own `auth`, permissions and scopes.\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-wiring/references/scheduler.md": "# Pikku Scheduled Tasks\n\n## API Reference\n\n### `wireScheduler(config)`\n\n```typescript\nimport { wireScheduler } from '#pikku/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### Giving a cron an identity\n\nA cron has no caller, so it runs with **no session at all**: it cannot invoke a\npermission- or scope-gated RPC, and nothing it writes can be attributed. A\nscheduled task is a machine principal — give it a session in the task's own\n`middleware`, exactly as a bearer-authenticated caller gets one:\n\n```typescript\nwireScheduler({\n name: 'bookingLifecycleDaily',\n schedule: '0 3 * * *',\n middleware: [cronSession],\n func: bookingLifecycleDaily,\n})\n```\n\n`runScheduledTask` builds its wire with a `sessionService`, so a `setSession`\nhere is the session the function is frozen with. See the machine-auth section of\n`pikku-middleware` for the factory and for why a cron is not a user row.\n\nA scheduler service running a task on someone's behalf can pass a session\ndirectly instead: `runScheduledTask({ name, session })`.\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-wiring/references/trigger.md": "# Pikku Trigger Wiring\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/trigger'\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/trigger'\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/trigger'\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\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-wiring/SKILL.md": "---\nname: pikku-wiring\ndescription: >-\n Use when exposing a Pikku function over a transport — HTTP routes and SSE, WebSocket channels,\n typed realtime pub/sub, internal and exposed RPC, queue workers, cron schedules, event triggers,\n MCP tools/resources/prompts, CLI commands, or a Slack gateway. Covers choosing the wiring, the\n model every wiring shares, and what differs: which function type each needs, where a session\n comes from, and which calls throw instead of returning. TRIGGER when: code uses wireHTTP,\n defineHTTPRoutes, wireChannel, wireQueueWorker, wireScheduler, wireTrigger, wireTriggerSource,\n wireCLI, wireMCPResource, wireMCPPrompt, `mcp: true`, `sse: true`, `expose: true`, rpc.invoke,\n SlackGatewayAdapter, or the user asks how to expose, route, schedule, queue, stream or publish a\n function. DO NOT TRIGGER when: writing the function body itself (use pikku-concepts), declaring\n authorization (use pikku-auth), or serving the app on a runtime (use pikku-deploy).\ninstallGroups: [core]\n---\n\n# Pikku 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\n## Before you start\n\n```bash\npikku info functions --verbose # existing functions, their types, tags, middleware\npikku info tags --verbose # project organisation and naming conventions\npikku info middleware --verbose # what middleware is already applied\n```\n\nFollow the patterns you find. Option tables and exact signatures come from\n`pikku doc` — run `pikku doc --ai` for the installed surface. This skill is what\nthe compiler cannot tell you: which wiring to reach for, and what changes when\nyou move a function from one to another.\n\n## What a wiring is\n\nA **function** owns behaviour, its `input`/`output` schemas, and its\nauthorization. A **wiring** owns only the transport: how a caller reaches that\nfunction. The same function can be wired to several transports at once, which is\nwhy nothing transport-specific belongs in its body.\n\nThree consequences that hold for every wiring below:\n\n- **Input and output types are never declared on the wiring.** They come from the\n function's own `input:`/`output:` schemas. Route params, query params and body\n are merged into the function's `data` argument.\n- **Permissions are never declared on the wiring.** Wire-level permissions were\n removed in #972 — declare them on the function (`pikkuFunc({ permissions })`,\n see `pikku-auth`) or app-wide via `addGlobalPermission`. Tags and\n patterns now target *middleware* only.\n- **The wire is the third argument**, not a service. `channel`, `rpc`, `session`,\n `setSession`, `mcp`, `queue`, `scheduledTask` and `cli` all live there.\n\n## Import from `#pikku/*`, never `@pikku/core/*`\n\nEvery wiring factory has two versions. The generated `#pikku/*` entrypoint binds\nit to your project's service, session and middleware types; the `@pikku/core/*`\nexport is the unbound generic. **Both compile.** Importing from core costs you\nexactly the typing that makes the wiring worth having, silently.\n\n| Wiring | Import from |\n| --- | --- |\n| `wireHTTP`, `defineHTTPRoutes`, `wireHTTPRoutes` | `#pikku/http` |\n| `wireChannel`, `defineChannelRoutes` | `#pikku/channel` |\n| `wireQueueWorker` | `#pikku/queue` |\n| `wireScheduler` | `#pikku/scheduler` |\n| `wireTrigger`, `wireTriggerSource`, `pikkuTriggerFunc` | `#pikku/trigger` |\n| `wireCLI`, `pikkuCLICommand`, `pikkuCLIRender` | `#pikku/cli` |\n| `pikkuMCPToolFunc`, `pikkuMCPResourceFunc`, `pikkuMCPPromptFunc`, `wireMCPResource`, `wireMCPPrompt` | `#pikku/mcp` |\n\n## Pick a wiring\n\n| Reach for | When | Reference |\n| --- | --- | --- |\n| **HTTP** | REST endpoints, web APIs, and SSE streams (`sse: true`, `get` only) | `references/http.md` |\n| **Channel** | A hand-designed WebSocket protocol with your own action routing | `references/channel.md` |\n| **Realtime** | Typed pub/sub push — the scaffolded `/events` channel and SSE topics | `references/realtime.md` |\n| **RPC** | One function calling another, or dispatching a name from outside | `references/rpc.md` |\n| **Queue** | Reliable background work that must survive a crash and retry | `references/queue.md` |\n| **Scheduler** | Recurring work on a cron expression | `references/scheduler.md` |\n| **Trigger** | Reacting in-process to an external event source (Redis pub/sub, PG LISTEN) | `references/trigger.md` |\n| **MCP** | Exposing functions to an AI assistant as tools, resources or prompts | `references/mcp.md` |\n| **CLI** | A terminal program with commands, subcommands and options | `references/cli.md` |\n| **Gateway** | An inbound integration from a third-party product — Slack is the shipped adapter | `references/gateway-slack.md` |\n\nRealtime and Channel are the pair most often confused. If the shape is \"server\npushes typed events to subscribers\", use Realtime — `pikku enable events`\ngenerates the channel, the SSE route and the cleanup for you. Reach for Channel\nonly when the client also sends structured messages you need to route on.\n\n## What differs, and where it bites\n\n### Each wiring demands a particular function type\n\n| Wiring | Function type | Why |\n| --- | --- | --- |\n| HTTP, Channel, Queue, CLI, MCP | `pikkuFunc` / `pikkuSessionlessFunc` | Ordinary request/response |\n| Scheduler | **`pikkuVoidFunc`** | A cron has no input and no caller to return to |\n| Trigger *source* | **`pikkuTriggerFunc`** | Runs **once at startup**, not once per event |\n\nA trigger source is the one that surprises people: it sets up a listener, calls\n`trigger.invoke(...)` per event, and returns a teardown function. It receives\n**singleton services only** — no session, no request, no per-wire services,\nbecause the listener outlives every event it emits.\n\n### Where a session comes from is not uniform\n\nAn HTTP or channel caller arrives with credentials and middleware mints a\nsession. Nothing else does.\n\n- **A cron runs with no session at all.** It cannot invoke a permission- or\n scope-gated RPC, and nothing it writes can be attributed. A scheduled task is a\n machine principal — give it one in the task's own `middleware`, exactly as a\n bearer-authenticated caller gets one. See the machine-auth section of\n `pikku-middleware`.\n- **A queue worker and a trigger handler are the same case.** Whatever identity\n they need is minted in middleware, not inherited.\n- **A channel authenticates per action.** `setSession` is on the wire, and an\n `auth: false` action (conventionally `authenticate`) is how the session is\n established mid-connection.\n- **`auth` on `wireCLI` guards only the generated websocket channel.** A locally\n executed CLI has no connection to authenticate, so it is not a way to require a\n session for local runs.\n\n### Some control-flow calls throw instead of returning\n\n`wire.scheduledTask.skip(reason)` and `wire.queue.discard(reason)` both read like\nan early return and are not — they throw, so nothing after the call runs and no\n`return` is needed. The consequence lands in middleware: a `try/catch` around\n`await next()` catches a skip or a discard and reports it as a failure. If your\nmiddleware distinguishes success from failure, let those pass through rather than\nlogging them as errors.\n\n### Delivery semantics differ, and that is usually the real choice\n\n| Wiring | Delivery | Runs where |\n| --- | --- | --- |\n| Trigger | **At-most-once**, synchronous | In-process, alongside the listener |\n| Queue | **At-least-once** with retries and a dead-letter queue | Distributed workers |\n| Scheduler | Depends on the runtime — see below | Wherever the scheduler service runs |\n\nReach for a trigger to react immediately, and a queue when the work must not be\nlost. A trigger that must not drop events is a queue with extra steps.\n\nScheduled tasks are the trap: on serverless runtimes the same `wireScheduler`\ndeclaration behaves three different ways, and the deployment unit rather than the\ncron expression can decide which tasks fire. `pikku-deploy` has the comparison.\n\n### Codegen owns several wirings — do not hand-write them\n\n| Turn it on | Codegen writes | Never hand-write |\n| --- | --- | --- |\n| `pikku enable rpc` | `rpc-public.gen.ts` — the `POST /rpc/:rpcName` route | A second route on the same path collides |\n| `pikku enable events` | `events.gen.ts` — the `/events` channel and `GET /events/:topic` | A hand-rolled `/events` misses disconnect cleanup |\n| `wireCLI` | `<program>-channel.gen.ts` — the same commands over a channel | It is regenerated on every build |\n\nEnabling the RPC endpoint says the endpoint exists, not who may call it — each\n`expose: true` function is still gated by its own `auth`, permissions and scopes.\n\n## What NOT to do\n\n- Do not import a wiring factory from `@pikku/core/*`. It compiles and silently\n drops your project's types; use the `#pikku/*` entrypoint.\n- Do not put `permissions` on a wiring. They were removed in #972 and belong on\n the function.\n- Do not declare input or output types on a wiring — they come from the\n function's schemas.\n- Do not `return` after `skip()` or `discard()`, and do not let middleware\n report them as failures.\n- Do not hand-write `/rpc/:rpcName`, `/events`, or a CLI's channel file.\n- Do not reach for a trigger when losing an event matters — use a queue.\n- Do not put `sse: true` on anything but a `get`, or `query` on anything but a\n `post`; the config union rejects both rather than failing at runtime.\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```\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({\n method: 'post',\n route: '/onboard',\n func: workflowStart('onboardUser'),\n})\n\n// Execute workflow steps (called by the orchestrator)\nwireHTTP({\n method: 'post',\n route: '/onboard/run',\n func: workflow('onboardUser'),\n})\n\n// Check workflow status\nwireHTTP({\n method: 'get',\n route: '/onboard/status/:runId',\n func: workflowStatus('onboardUser'),\n})\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-wiring) or scheduled tasks (use\n pikku-wiring).\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## Decide FIRST: should this even BE a workflow?\n\nThe deciding question is: **does any part of this cross an external boundary that can fail and MUST NOT be lost or double-run** — a payment authorised/captured through a provider, a third-party API call, an email/webhook, a wait for approval? If yes → workflow (durability, retries, restart-survival, and a visible run). If it's **a single algorithm done in one shot, purely local, and not reused elsewhere** → a plain `pikkuFunc` is correct; do NOT wrap it in a workflow.\n\n- **Checkout WITH payment → workflow.** Get cart → compute total → **(atomic: create order + order items, deduct stock, clear cart)** → **charge payment through the provider** → send confirmation email. It's a workflow because the payment leg (and the email) are external and must be **retried, not lost, and not charged twice** across a restart — and the user benefits from seeing where the run is.\n- **Checkout with NO external payment** — e.g. it just records the order and decrements stock in one transaction, nothing leaves the process — is a **single-shot algorithm**: a plain `pikkuFunc` wrapping one `kysely.transaction`. Not a workflow. A workflow here would add durability machinery for a thing that already commits atomically in one shot.\n- **One durable step is NOT a workflow — it's a queue worker.** A lone side-effect that must be retried / not lost (send one email, fire one webhook, one external charge) → a **queue worker** (`pikku-queue`), enqueued fire-and-forget. A workflow adds a step graph for a thing that has no steps to orchestrate. (A single non-durable step is just a direct RPC call.)\n- Also workflows: onboarding sequences, settlements/payouts, digests and batch sends, anything that waits (`sleep`/`suspend`) or fans out with retries — the common thread is **multiple** steps or a durable wait, never a single step.\n\n**HARD RULE — never a single-RPC (one-step) workflow.** A workflow whose body is one `workflow.do('x', 'someRpc', …)` is a mislabeled durable function, not orchestration. Route by durability, NOT into a workflow:\n\n- **Not durable** — the caller wants the result now / it can just run in-request → **call the RPC directly** (this is also the synchronous path). No workflow, no queue.\n- **Durable** — must be **retried / not lost / survive a restart** (one email, one webhook, one external charge) → a **queue worker** (`wireQueueWorker` + `queueService.add(...)`). Fire-and-forget, retried by the queue.\n\nThere is no \"one-step workflow is justified for the durability\" exception — durability for a single step is a QUEUE. A workflow earns its name only with genuine multi-step orchestration (a `sleep`/`suspend` wait, fan-out, or a saga).\n\n**Atomicity is a TRANSACTION, not a workflow.** All-or-nothing multi-write units (create order + items + deduct stock + clear cart) belong inside ONE `kysely.transaction(async (trx) => { … })` — a single step or a single plain `pikkuFunc` — **never split across workflow steps.** A step is a unit of RETRY and REPLAY, not a unit of atomicity: pikku opens no transaction around `workflow.do`, so a step that does three writes and throws on the third leaves the first two committed, and the retry runs them again. Spreading one logical transaction over several steps is the same failure one level up. Your writes are atomic only where YOU opened a transaction, so open one inside the step (reach for compensating/saga steps only when you truly need cross-service rollback). So a payment checkout is a workflow whose _atomic DB writes are ONE step that opens ONE transaction_, with the payment charge and email as the other durable steps around it.\n\n**A retried step re-runs its side effects.** Replay caching only covers steps that already\nreturned; a step that failed — or that timed out after the provider accepted it — runs again\nfrom the top, so a charge, an email or a webhook can fire twice. Durability is at-least-once,\nnot exactly-once. Pass a stable idempotency key the provider deduplicates on, derived from the\nworkflow's own data rather than generated inside the step:\n\n```typescript\nawait workflow.do('Charge', 'chargePayment', {\n orderId: data.orderId,\n amount: data.amount,\n idempotencyKey: `order-${data.orderId}-charge`,\n})\n```\n\n`randomUUID()` or `Date.now()` inside the step is a different key on every attempt, which is\nthe double-charge. Where the provider has no such header, make the step itself idempotent —\ncheck for the effect before performing it, or record a unique row that the second attempt\ncollides with.\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 — the function leaf does not re-export them (TS2305)\nimport { pikkuWorkflowFunc } from '#pikku/workflow'\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" };