@pikku/skills 0.12.10 → 0.12.11

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +768 -0
  2. package/dist/skills.gen.js +1 -1
  3. package/package.json +4 -4
  4. package/skills/pikku-addon/SKILL.md +17 -11
  5. package/skills/pikku-addon/references/addon-package-manifest.md +2 -2
  6. package/skills/pikku-agent/SKILL.md +3 -3
  7. package/skills/pikku-audit/SKILL.md +28 -13
  8. package/skills/pikku-aws/SKILL.md +2 -2
  9. package/skills/pikku-better-auth/SKILL.md +97 -17
  10. package/skills/pikku-build-app/SKILL.md +621 -0
  11. package/skills/pikku-build-app/references/multi-app.md +117 -0
  12. package/skills/pikku-build-app/references/ship.md +98 -0
  13. package/skills/pikku-build-app/references/theming.md +70 -0
  14. package/skills/pikku-build-platform/SKILL.md +239 -0
  15. package/skills/pikku-build-quick/SKILL.md +238 -0
  16. package/skills/pikku-cli/SKILL.md +7 -7
  17. package/skills/pikku-cli/references/complete-example.md +1 -1
  18. package/skills/pikku-concepts/SKILL.md +5 -2
  19. package/skills/pikku-concepts/references/concept-mapping.md +1 -1
  20. package/skills/pikku-config/SKILL.md +5 -3
  21. package/skills/pikku-deploy-azure/SKILL.md +5 -4
  22. package/skills/pikku-emails/SKILL.md +5 -5
  23. package/skills/pikku-fabric/SKILL.md +27 -3
  24. package/skills/pikku-fabric-debug/SKILL.md +1 -1
  25. package/skills/pikku-feature/SKILL.md +5 -4
  26. package/skills/pikku-http/SKILL.md +4 -4
  27. package/skills/pikku-http/references/http-options.md +13 -13
  28. package/skills/pikku-i18n/SKILL.md +2 -1
  29. package/skills/pikku-info/SKILL.md +1 -1
  30. package/skills/pikku-knowledge/SKILL.md +13 -13
  31. package/skills/pikku-mcp/SKILL.md +4 -4
  32. package/skills/pikku-middleware/SKILL.md +5 -5
  33. package/skills/pikku-middleware/references/middleware-patterns.md +14 -8
  34. package/skills/pikku-n8n-import/SKILL.md +12 -12
  35. package/skills/pikku-n8n-import/SPEC.md +3 -0
  36. package/skills/pikku-n8n-import/references/code-translation.md +26 -22
  37. package/skills/pikku-n8n-import/references/loops-and-control.md +7 -7
  38. package/skills/pikku-paraglide/SKILL.md +11 -6
  39. package/skills/pikku-permissions/SKILL.md +19 -15
  40. package/skills/pikku-product-second-opinion/README.md +3 -3
  41. package/skills/pikku-product-second-opinion/example/sample-report.md +15 -12
  42. package/skills/pikku-product-second-opinion/references/report-template.md +10 -7
  43. package/skills/pikku-queue/SKILL.md +1 -1
  44. package/skills/pikku-react/SKILL.md +53 -13
  45. package/skills/pikku-realtime/SKILL.md +51 -19
  46. package/skills/pikku-rtl/SKILL.md +1 -1
  47. package/skills/pikku-scenario/SKILL.md +126 -14
  48. package/skills/pikku-schedule/SKILL.md +6 -1
  49. package/skills/pikku-schema-ajv/SKILL.md +2 -2
  50. package/skills/pikku-schema-cfworker/SKILL.md +1 -1
  51. package/skills/pikku-security/SKILL.md +9 -5
  52. package/skills/pikku-services/SKILL.md +27 -18
  53. package/skills/pikku-services/references/audit-wire-service.md +14 -8
  54. package/skills/pikku-software-archaeology/scripts/validate.mjs +146 -69
  55. package/skills/pikku-tag-middleware/SKILL.md +1 -0
  56. package/skills/pikku-template-clone/SKILL.md +2 -1
  57. package/skills/pikku-trigger/SKILL.md +3 -3
  58. package/skills/pikku-websocket/SKILL.md +4 -3
  59. package/skills/pikku-workflow/SKILL.md +2 -2
  60. package/skills/pikku-workflow/references/workflow-reference.md +24 -10
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pikku-react
3
- description: 'Set up @pikku/react in a React app: PikkuProvider context, createPikku factory, and the usePikkuRPC / usePikkuFetch hooks for direct (non-React-Query) calls. TRIGGER when: the user is bootstrapping a React frontend that talks to a Pikku backend, asks how to wire `PikkuProvider`, or needs to make one-off RPC calls outside of useQuery/useMutation. DO NOT TRIGGER when: the user is asking about useQuery/useMutation hooks (use pikku-react-query) or about workflows (use pikku-workflows-client).'
3
+ description: 'Set up @pikku/react in a React app: PikkuProvider context, createPikku factory, and the usePikkuRPC / usePikkuFetch hooks for direct (non-React-Query) calls. TRIGGER when: the user is bootstrapping a React frontend that talks to a Pikku backend, asks how to wire `PikkuProvider`, or needs to make one-off RPC calls outside of useQuery/useMutation. TRIGGER when: user asks about the dev actor switcher, "sign in as" / quick-login UI, useDevActors, VITE_DEV_ACTORS, or the app-missing-actor-quick-login validate finding. DO NOT TRIGGER when: the user is asking about useQuery/useMutation hooks (use pikku-react-query) or about workflows (use pikku-workflows-client).'
4
4
  installGroups: [core]
5
5
  ---
6
6
 
@@ -58,8 +58,8 @@ export function apiUrl(): string {
58
58
  ```
59
59
 
60
60
  **Never fall back to `http://localhost:3000`.** `import.meta.env.VITE_API_URL`
61
- is substituted by Vite at *build* time, so any deploy that supplies the URL as
62
- a *runtime* env var or platform binding leaves it `undefined` in the shipped
61
+ is substituted by Vite at _build_ time, so any deploy that supplies the URL as
62
+ a _runtime_ env var or platform binding leaves it `undefined` in the shipped
63
63
  bundle — the fallback is then the only branch that ever runs in the browser. A
64
64
  localhost fallback means every request from a deployed app goes to the user's
65
65
  own machine. `origin + '/api'` is same-origin, needs no build-time knowledge of
@@ -173,17 +173,17 @@ helpers live in **pikku-realtime**.
173
173
 
174
174
  ## When to reach for what
175
175
 
176
- | Need | Use |
177
- | ----------------------------------- | --------------------------------------------- |
178
- | Render data, dedupe + cache | **usePikkuQuery** (react-query) |
179
- | Trigger a write, wait for result | **usePikkuMutation** (react-query) |
180
- | Paginate | **usePikkuInfiniteQuery** (react-query) |
181
- | One-off call from an event handler | `usePikkuRPC()` direct |
182
- | Hit a REST endpoint (not RPC) | `usePikkuFetch()` |
176
+ | Need | Use |
177
+ | ----------------------------------- | -------------------------------------------------- |
178
+ | Render data, dedupe + cache | **usePikkuQuery** (react-query) |
179
+ | Trigger a write, wait for result | **usePikkuMutation** (react-query) |
180
+ | Paginate | **usePikkuInfiniteQuery** (react-query) |
181
+ | One-off call from an event handler | `usePikkuRPC()` direct |
182
+ | Hit a REST endpoint (not RPC) | `usePikkuFetch()` |
183
183
  | Run one named workflow | `usePikkuWorkflow('name')` → `.start/.run/.status` |
184
184
  | Talk to one named AI agent | `usePikkuAgent('name')` → `.run/.stream/.approve` |
185
- | Longer-running workflow UX | **pikku-workflows-client** |
186
- | Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |
185
+ | Longer-running workflow UX | **pikku-workflows-client** |
186
+ | Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |
187
187
 
188
188
  The first three live in your generated `api.gen.ts` (see the
189
189
  **pikku-react-query** skill). This skill covers the rest.
@@ -203,7 +203,7 @@ const state = await workflow.status(runId)
203
203
  ## Authentication
204
204
 
205
205
  Auth is handled at the `PikkuFetch` layer, and `createPikku`'s options object
206
- *is* `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a
206
+ _is_ `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a
207
207
  `fetchOptions` key:
208
208
 
209
209
  ```tsx
@@ -228,6 +228,46 @@ pikku.fetch.setHeader('x-tenant', tenantId)
228
228
  `authHeaders.jwt` becomes `Authorization: Bearer …` and `authHeaders.apiKey`
229
229
  becomes `X-API-KEY`; setting a JWT takes precedence over an API key.
230
230
 
231
+ ### Dev actor sign-in (`useDevActors`)
232
+
233
+ The dev-only "Sign in as …" control: one click signs in as a declared scenario
234
+ persona with no password, so the app can be reviewed as each kind of user.
235
+ `pikku fabric validate` **requires** any frontend with a login screen to ship one
236
+ (`app-missing-actor-quick-login-<app>`) — without it a reviewer is locked out of
237
+ their own sandbox.
238
+
239
+ ```tsx
240
+ import { useDevActors } from '@pikku/react'
241
+
242
+ const { actors, signInAs, pendingEmail, isPending, error } = useDevActors({
243
+ // Gate both reads on the bundler's dev flag so the secret cannot reach a
244
+ // production bundle. The sandbox dev server bakes them from your personas.
245
+ actors: import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined,
246
+ secret: import.meta.env.DEV
247
+ ? import.meta.env.VITE_SCENARIO_ACTOR_SECRET
248
+ : undefined,
249
+ apiUrl: apiUrl(),
250
+ onSignedIn: () => navigate({ to: '/' }),
251
+ })
252
+ ```
253
+
254
+ - **It is UI-free**, so render it however you like. For the default rendering use
255
+ `<DevActorSwitcher />` from `@pikku/mantine/dev` — a separate entry point from
256
+ `@pikku/mantine/core`, whose contract is "drop-in alias for `@mantine/core`"
257
+ and so must not export components Mantine has no counterpart for.
258
+ - **`actors` is empty unless the host supplied both a list and a secret**, so a
259
+ production build renders nothing without you testing for it.
260
+ - **It takes `onSignedIn` rather than a router**, and takes the env values rather
261
+ than reading them, because how env is spelled is a bundler fact
262
+ (`import.meta.env.VITE_*` vs `process.env.NEXT_PUBLIC_*`).
263
+ - The underlying `signInAsActor()` and `parseDevActors()` are exported too, for a
264
+ non-React caller. The endpoint only accepts rows flagged `actor: true`, so it
265
+ can never impersonate a real user — see **pikku-better-auth**.
266
+
267
+ Do not hand-write the `devActors()` / `signInAsActor()` pair per app; that
268
+ copy-paste, including the `import.meta.env.DEV` gate, is exactly what this
269
+ replaced.
270
+
231
271
  ## What NOT to do
232
272
 
233
273
  - Don't instantiate `PikkuFetch`/`PikkuRPC` inside a component — `createPikku`
@@ -97,19 +97,38 @@ a one-word change, not a different import:
97
97
 
98
98
  ```ts
99
99
  export class PikkuRealtime {
100
- constructor(options?: { reconnect?: boolean; reconnectDelayMs?: number; reconnectMaxDelayMs?: number })
101
- setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
100
+ constructor(options?: {
101
+ reconnect?: boolean
102
+ reconnectDelayMs?: number
103
+ reconnectMaxDelayMs?: number
104
+ })
105
+ setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
102
106
 
103
107
  // WebSocket at /events — many topics on one connection
104
- subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void
105
- unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: (data: EventHubTopics[K]) => void): void
108
+ subscribe<K extends keyof EventHubTopics>(
109
+ topic: K,
110
+ handler: (data: EventHubTopics[K]) => void
111
+ ): () => void
112
+ unsubscribe<K extends keyof EventHubTopics>(
113
+ topic: K,
114
+ handler?: (data: EventHubTopics[K]) => void
115
+ ): void
106
116
 
107
117
  // SSE at GET /events/:topic — one EventSource per topic
108
- subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }
118
+ subscribeToTopic<K extends keyof EventHubTopics>(
119
+ topic: K,
120
+ handler: (data: EventHubTopics[K]) => void
121
+ ): { close: () => void }
109
122
 
110
123
  // generic escape hatches — see references/other-routes.md
111
- subscribeToSSE<T>(path: string, handler: (data: T) => void): { close: () => void }
112
- connectToChannel(channelRoute: string, protocols?: string | string[]): WebSocket
124
+ subscribeToSSE<T>(
125
+ path: string,
126
+ handler: (data: T) => void
127
+ ): { close: () => void }
128
+ connectToChannel(
129
+ channelRoute: string,
130
+ protocols?: string | string[]
131
+ ): WebSocket
113
132
 
114
133
  close(): void
115
134
  }
@@ -137,14 +156,16 @@ Envelope the payload as `{ topic, data }`: the generated client dispatches on th
137
156
  `topic` field, so a bare payload arrives but no handler fires.
138
157
 
139
158
  ```ts
140
- import { pikkuFunc } from '#pikku'
159
+ import { pikkuFunc } from '#pikku/function'
141
160
 
142
161
  export const createTodo = pikkuFunc({
143
162
  input: CreateTodoInput,
144
163
  output: CreateTodoOutput,
145
164
  func: async ({ kysely, eventHub }, data) => {
146
165
  const todo = await kysely
147
- .insertInto('todos').values(data).returningAll()
166
+ .insertInto('todos')
167
+ .values(data)
168
+ .returningAll()
148
169
  .executeTakeFirstOrThrow()
149
170
 
150
171
  await eventHub.publish('todo-created', null, {
@@ -160,7 +181,9 @@ A thin helper removes the duplication:
160
181
 
161
182
  ```ts
162
183
  async function publishEvent<K extends keyof EventHubTopics>(
163
- hub: EventHubService<EventHubTopics>, topic: K, data: EventHubTopics[K]
184
+ hub: EventHubService<EventHubTopics>,
185
+ topic: K,
186
+ data: EventHubTopics[K]
164
187
  ) {
165
188
  return hub.publish(topic, null, { topic, data })
166
189
  }
@@ -187,7 +210,9 @@ const pikku = createPikku(
187
210
  // pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.
188
211
 
189
212
  createRoot(document.getElementById('root')!).render(
190
- <PikkuProvider pikku={pikku}><App /></PikkuProvider>
213
+ <PikkuProvider pikku={pikku}>
214
+ <App />
215
+ </PikkuProvider>
191
216
  )
192
217
  ```
193
218
 
@@ -214,7 +239,8 @@ function TodoList() {
214
239
  useEffect(() => {
215
240
  // WebSocket multi-topic:
216
241
  const off = realtime.subscribe('todo-created', ({ todo }) =>
217
- setTodos((prev) => [...prev, todo]))
242
+ setTodos((prev) => [...prev, todo])
243
+ )
218
244
  return off
219
245
 
220
246
  // Single-topic SSE (auto-cleanup on close) instead:
@@ -223,7 +249,13 @@ function TodoList() {
223
249
  // return () => sub.close()
224
250
  }, [realtime])
225
251
 
226
- return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
252
+ return (
253
+ <ul>
254
+ {todos.map((t) => (
255
+ <li key={t.id}>{t.title}</li>
256
+ ))}
257
+ </ul>
258
+ )
227
259
  }
228
260
  ```
229
261
 
@@ -235,12 +267,12 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
235
267
 
236
268
  ## When to pick which transport
237
269
 
238
- | Need | Use |
239
- | ------------------------------------------ | ----------------------------- |
240
- | Many topics in one connection | `realtime.subscribe` |
241
- | Single live stream, simple cleanup | `realtime.subscribeToTopic` |
242
- | Bidirectional (client also sends messages) | `realtime.subscribe` |
243
- | WebSockets blocked by infra | `realtime.subscribeToTopic` |
270
+ | Need | Use |
271
+ | ------------------------------------------ | --------------------------- |
272
+ | Many topics in one connection | `realtime.subscribe` |
273
+ | Single live stream, simple cleanup | `realtime.subscribeToTopic` |
274
+ | Bidirectional (client also sends messages) | `realtime.subscribe` |
275
+ | WebSockets blocked by infra | `realtime.subscribeToTopic` |
244
276
 
245
277
  Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
246
278
  all topics for the dead channel id). Don't write manual cleanup unless you're
@@ -18,7 +18,7 @@ Set `dir` **once at the document root** from the active locale, then let the
18
18
  browser and Mantine mirror everything — _provided_ every custom style is written
19
19
  **flow-relative** (start/end), never **physical** (left/right). Get those two
20
20
  things right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component
21
- *layout* code — directional icons still need one manual step, covered below.
21
+ _layout_ code — directional icons still need one manual step, covered below.
22
22
 
23
23
  ## Agent Operating Procedure
24
24
 
@@ -46,7 +46,7 @@ Scenarios live in `srcDirectories` like any other function — by convention `*.
46
46
  `pikkuScenario` comes from the **generated** workflow types, not `@pikku/core`:
47
47
 
48
48
  ```typescript
49
- import { pikkuScenario } from '#pikku/workflow/pikku-workflow-types.gen.js'
49
+ import { pikkuScenario } from '#pikku/scenario'
50
50
 
51
51
  export const orderSupportScenario = pikkuScenario<
52
52
  { value?: number },
@@ -175,7 +175,7 @@ Hooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs
175
175
  `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:
176
176
 
177
177
  ```typescript
178
- import { pikkuFeature } from '#pikku/workflow/pikku-workflow-types.gen.js'
178
+ import { pikkuFeature } from '#pikku/scenario'
179
179
  import {
180
180
  credentialLazyLoadScenario,
181
181
  credentialRoundTripScenario,
@@ -240,7 +240,7 @@ Utilities are **not steps**. They are plain exported functions, they take the br
240
240
 
241
241
  ```typescript
242
242
  // shop.browser.ts — shared actions. Not steps: nothing here is an intent.
243
- import type { PikkuBrowserWire } from '@pikku/core/workflow'
243
+ import type { PikkuBrowserWire } from '#pikku/scenario'
244
244
  import type {} from '@pikku/playwright'
245
245
 
246
246
  /** Arrive on the shop, from wherever the browser happens to be. */
@@ -377,6 +377,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
377
377
  { status: string }
378
378
  >({
379
379
  name: 'seesTheOrderConfirmed',
380
+ // Both bindings run as the persona, so the step declares one and the runner
381
+ // injects `wire.actor` — non-optional in every binding.
382
+ actor: true,
380
383
  template: 'sees order {orderId} confirmed',
381
384
  // Both run on `--run browser`. Each returns what it observed, and the runner
382
385
  // compares them — so this fails when the page disagrees with the database.
@@ -385,8 +388,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
385
388
  .locate({ testId: 'order-status', where: { 'data-order': orderId } })
386
389
  .getAttribute('data-status'),
387
390
  }),
388
- default: async ({ rpc }, { orderId }) => ({
389
- status: (await rpc.invoke('getOrder', { orderId })).status,
391
+ // Through the actor, not through a `rpc` service — see "What a step is given".
392
+ default: async (_services, { orderId }, { actor }) => ({
393
+ status: (await actor.invoke('getOrder', { orderId })).status,
390
394
  }),
391
395
  })
392
396
  ```
@@ -408,8 +412,7 @@ Assertions with no possible browser witness are a different thing and should not
408
412
  `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.
409
413
 
410
414
  ```typescript
411
- import { pikkuScenarioStep } from '#pikku/workflow/pikku-workflow-types.gen.js'
412
- import { requireActor } from '@pikku/core/workflow'
415
+ import { pikkuScenarioStep } from '#pikku/scenario'
413
416
 
414
417
  export const buysAnApple = pikkuScenarioStep<
415
418
  { qty: number },
@@ -418,8 +421,9 @@ export const buysAnApple = pikkuScenarioStep<
418
421
  name: 'buysAnApple',
419
422
  description: 'buys an apple',
420
423
  template: 'buys {qty} apples',
421
- default: async (_services, { qty }, { scenarioStep }) => {
422
- return await requireActor(scenarioStep).invoke('placeOrder', { qty })
424
+ actor: true,
425
+ default: async (_services, { qty }, { actor }) => {
426
+ return await actor.invoke('placeOrder', { qty })
423
427
  },
424
428
  })
425
429
  ```
@@ -443,12 +447,63 @@ Rules that bite:
443
447
  - **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`).
444
448
  - **Steps are not RPCs.** They are deliberately never network-callable — a browser-driving step must not be.
445
449
  - **`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.
446
- - **`actor` and `env` are optional on the wire**, because a pure assertion step needs neither. Narrow them with `requireActor(scenarioStep)` and `requireScenarioEnv(scenarioStep)` from `@pikku/core/workflow` rather than a local guard — both name the step and say what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.
450
+ - **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.
451
+ - **`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.
447
452
  - **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.
448
453
  - **Step results are persisted**, so return JSON-serialisable data — never a `Locator` or a client object.
449
454
  - **`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.
450
455
  - 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.
451
456
 
457
+ #### What a step is given
458
+
459
+ A step has the signature of an ordinary pikku function, which makes it look as
460
+ though it runs where the application runs. It does not — **it runs in the CLI
461
+ process**, and the services object is built there, by hand:
462
+
463
+ ```typescript
464
+ { logger, workflowService, workflowRunService, agentRunner? }
465
+ ```
466
+
467
+ That is the whole list. There is no `kysely`, no `variables`, no `secrets`, and
468
+ none of the project's own singleton or wire services. A step that destructures
469
+ one gets `undefined` and fails on first use — `Cannot read properties of
470
+ undefined (reading 'selectFrom')` — which reads like a broken container and is
471
+ not.
472
+
473
+ `rpc` is the trap worth naming, because it is present and it throws. It is a
474
+ `guardRpc` whose every member refuses:
475
+
476
+ > Scenario tried to run 'getOrder' as an internal step. Every workflow.do in a
477
+ > scenario must carry { actor: actors.x } so it executes against 'local'.
478
+
479
+ The same guard covers `rpc.agent.run/stream/resume/approve/interrupt` and
480
+ `startWorkflow`.
481
+
482
+ This is the design, not a gap: **everything a step touches of the application
483
+ goes over the wire as somebody.** A test that could reach into the database
484
+ would be testing a different program from the one a person uses. So there are
485
+ exactly three ways in, and they are all through the actor:
486
+
487
+ - `actor.invoke(name, data)` — typed over the exposed RPC map, carrying the
488
+ actor's session. Declare `actor: true` and destructure it off the wire.
489
+ - `.invokeRaw(name, data, { headers })` — same call, reporting
490
+ `{ status, ok, body }`, for when the refusal is the assertion.
491
+ - a plain `fetch` against `requireScenarioEnv(scenarioStep).apiUrl`, for
492
+ anything not an RPC — a websocket, a file upload, a webhook.
493
+
494
+ Two consequences follow, and both shape how steps get written:
495
+
496
+ - **A step cannot observe anything the app does not publish.** If a test needs a
497
+ fact the client never sees, the fix is to emit it on the stream or expose it
498
+ as an RPC — which usually improves the product, since a client debugging the
499
+ same problem needed it too.
500
+ - **`agentRunner` is conditional.** It is built only when the project declares
501
+ agents, and `createDevAgentRunner` needs a base URL *and* a key together
502
+ (`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the LiteLLM pair). With a key alone
503
+ it returns nothing and `agentRunner` is `undefined`, so `actor.converse`
504
+ fails before the persona says anything. A suite that would rather own its own
505
+ model can pass an `llm` to `runConversation` instead of relying on this one.
506
+
452
507
  ### Browser steps
453
508
 
454
509
  Declaring 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.
@@ -468,7 +523,9 @@ export const opensTheCart = pikkuScenarioStep<
468
523
  await browser.goto(path)
469
524
  return { url: browser.page.url() }
470
525
  },
471
- default: async ({ rpc }) => ({ url: (await rpc.invoke('getCart', {})).url }),
526
+ default: async (_services, _data, { actor }) => ({
527
+ url: (await actor.invoke('getCart', {})).url,
528
+ }),
472
529
  })
473
530
  ```
474
531
 
@@ -525,11 +582,68 @@ A persona holds only what is true of that kind of person for the app's whole lif
525
582
 
526
583
  `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.
527
584
 
585
+ #### Declaring personas in TypeScript
586
+
587
+ `definePersonas({ … })` is the code form of the block above, and there may be
588
+ **one call in the whole codebase** — one place to read the set from, one place
589
+ to add to it. A second anywhere, including in the same file, is a critical.
590
+ Generated files are exempt and never claim the slot.
591
+
592
+ > [!WARNING]
593
+ > The declaration is **read from source, never evaluated** — the CLI writes it
594
+ > to JSON that a deployed stage carries without the app. So every value has to
595
+ > be statically knowable, and a value that is not comes out as `undefined`
596
+ > rather than as an error. Only `name` is checked, so a computed `personality`,
597
+ > `jobTitle` or `description` is dropped in silence and the persona runs with a
598
+ > blank temperament.
599
+
600
+ What that admits and what it does not:
601
+
602
+ ```typescript
603
+ personality: 'Wound up and short with it.' // read
604
+ personality: `Wound up and short with it.
605
+ Says what she wants in a few blunt words.` // read — no ${} in it
606
+ personality: 'Wound up. ' + 'Short with it.' // dropped, silently
607
+ personality: TEMPERAMENTS.impatient // dropped, silently
608
+ ```
609
+
610
+ A no-substitution template literal is a string literal as far as the reader is
611
+ concerned, so it is the way to write a long personality across several lines —
612
+ not a concatenation, and not a `prettier-ignore`d single line. Its newlines and
613
+ leading indentation are kept verbatim and reach the model that way, which is
614
+ harmless but worth knowing before you align it to the surrounding code.
615
+
616
+ One more thing worth knowing before writing a rich persona: **`actor.converse`
617
+ builds its prompt from `name`, `jobTitle`, `personality` and the scenario's
618
+ `task` only.** Fields like `disposition`, `goals` and `roles` are read and
619
+ stored, and the console shows them, but they do not reach the conversing
620
+ persona's instructions. Anything that must shape how someone talks belongs in
621
+ `personality` or in the task.
622
+
528
623
  An actor with no `persona` is its own persona, so a project that never declares any keeps working unchanged.
529
624
 
530
625
  - `environments.<name>.apiUrl` is required. `signInPath` defaults to `/auth/sign-in/actor`, `rpcPath` to `/rpc`.
531
626
  - **`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.
532
627
 
628
+ ### The same actors sign a human in
629
+
630
+ Declared actors are not only for automated runs. `signInPath` is Better Auth's
631
+ `actor` plugin (see `pikku-better-auth`), which any caller can post to — so the
632
+ frontend gets a one-click "Sign in as …" switcher over the **same** list, and an
633
+ app can be reviewed as each kind of user without anyone knowing a seed password.
634
+
635
+ The sandbox dev server bakes both halves into the frontend from the declared
636
+ personas: `VITE_DEV_ACTORS` (the JSON actor list) and
637
+ `VITE_SCENARIO_ACTOR_SECRET`. Neither is set in a production build, so the
638
+ control renders nothing there — but gate the reads on your bundler's dev flag
639
+ anyway (`import.meta.env.DEV ? … : undefined`) so the secret never reaches a
640
+ production bundle in the first place.
641
+
642
+ Do not hand-roll the switcher: `useDevActors()` (`pikku-react`) is the logic and
643
+ `<DevActorSwitcher />` from `@pikku/mantine/dev` is a ready rendering of it.
644
+ `pikku fabric validate` **requires** any frontend with a login screen to ship
645
+ one — without it a reviewer is locked out of their own sandbox.
646
+
533
647
  ## Running
534
648
 
535
649
  ```bash
@@ -592,9 +706,7 @@ The run resets coverage before each scenario and snapshots after, writing **`<ou
592
706
  "generatedAt": "…",
593
707
  "environment": "local",
594
708
  "scenarios": {
595
- "<name>": {
596
- /* FunctionCoverageReport */
597
- },
709
+ "<name>": {/* FunctionCoverageReport */},
598
710
  },
599
711
  }
600
712
  ```
@@ -51,7 +51,12 @@ development and a single-instance deployment, wrong for anything else.
51
51
  ### Scheduling a one-off RPC
52
52
 
53
53
  ```typescript
54
- const taskId = await schedulerService.scheduleRPC('5m', 'sendReminder', data, session)
54
+ const taskId = await schedulerService.scheduleRPC(
55
+ '5m',
56
+ 'sendReminder',
57
+ data,
58
+ session
59
+ )
55
60
  await schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null
56
61
  await schedulerService.getAllTasks() // pending one-offs only
57
62
  await schedulerService.unschedule(taskId) // true when it was still pending
@@ -52,7 +52,7 @@ called `schema` in the source, which reads backwards.
52
52
 
53
53
  - **Registration is name-keyed and never re-compiles.** A second
54
54
  `compileSchema('X', …)` with a different schema is a no-op; the first one wins
55
- for the process lifetime. `@pikku/schema-cfworker` *does* recompile on a
55
+ for the process lifetime. `@pikku/schema-cfworker` _does_ recompile on a
56
56
  changed value, so a dev hot-reload after codegen picks up a changed schema
57
57
  there but not here — restart the process instead.
58
58
  - **AJV is a module-level singleton**, shared by every `AjvSchemaService` you
@@ -62,7 +62,7 @@ called `schema` in the source, which reads backwards.
62
62
  become `1` — the wiring layer is what coerces, not this service.
63
63
  - `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)
64
64
  are enforced.
65
- - A failed validation throws `UnprocessableContentError` (a 422). A *missing*
65
+ - A failed validation throws `UnprocessableContentError` (a 422). A _missing_
66
66
  schema throws a bare string, `Missing validator for <name>` — not an `Error`,
67
67
  so `catch (e) { e.message }` reads `undefined`. That normally means codegen
68
68
  didn't run.
@@ -63,7 +63,7 @@ surface as behaviour changes rather than compile errors:
63
63
  underlying cause swallowed — check the schema by hand when you see it.
64
64
 
65
65
  A failed validation throws `UnprocessableContentError` (422) with the validator
66
- errors joined; a *missing* schema throws a bare string, `Missing validator for
66
+ errors joined; a _missing_ schema throws a bare string, `Missing validator for
67
67
  <name>`, not an `Error`.
68
68
 
69
69
  ## Usage Patterns
@@ -62,7 +62,7 @@ Apply these via `addHTTPMiddleware` in a wirings file:
62
62
 
63
63
  ```typescript
64
64
  import { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'
65
- import { addHTTPMiddleware } from '#pikku'
65
+ import { addHTTPMiddleware } from '#pikku/http'
66
66
 
67
67
  // JWT bearer token — reads Authorization header
68
68
  addHTTPMiddleware('*', [authBearer()])
@@ -118,14 +118,18 @@ response.
118
118
 
119
119
  ```typescript
120
120
  // permissions.ts
121
- import { pikkuAuth, pikkuPermission } from '#pikku'
121
+ import { pikkuAuth, pikkuPermission } from '#pikku/function'
122
122
 
123
- export const isAuthenticated = pikkuAuth(async (_services, session) => !!session)
124
- export const isVerified = pikkuAuth(async (_services, session) => !!session?.emailVerified)
123
+ export const isAuthenticated = pikkuAuth(
124
+ async (_services, session) => !!session
125
+ )
126
+ export const isVerified = pikkuAuth(
127
+ async (_services, session) => !!session?.emailVerified
128
+ )
125
129
 
126
130
  // wirings/auth.wiring.ts
127
131
  import { authCookie } from '@pikku/core/middleware'
128
- import { addHTTPMiddleware } from '#pikku'
132
+ import { addHTTPMiddleware } from '#pikku/http'
129
133
 
130
134
  addHTTPMiddleware('*', [
131
135
  authCookie({
@@ -38,7 +38,7 @@ pikku info tags --verbose # Understand project organization
38
38
  ### `pikkuServices(factory)` — singleton services (created once at startup)
39
39
 
40
40
  ```typescript
41
- import { pikkuServices } from '#pikku'
41
+ import { pikkuServices } from '#pikku/function'
42
42
  import { ConsoleLogger } from '@pikku/core/services'
43
43
  import { JoseJWTService } from '@pikku/jose'
44
44
 
@@ -61,7 +61,7 @@ export const createSingletonServices = pikkuServices(
61
61
  ### `pikkuWireServices(factory)` — per-request services (fresh per HTTP request, queue job, CLI command, etc.)
62
62
 
63
63
  ```typescript
64
- import { pikkuWireServices } from '#pikku'
64
+ import { pikkuWireServices } from '#pikku/function'
65
65
 
66
66
  export const createWireServices = pikkuWireServices(
67
67
  async (singletonServices, wire) => {
@@ -157,13 +157,16 @@ const getUser = pikkuFunc({
157
157
 
158
158
  **Never write a `if (!service) throw ...` existence guard in a function body.** It is dead code, and it defeats the platform.
159
159
 
160
- Optionality 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.
160
+ Optionality 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.
161
161
 
162
162
  The 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:
163
163
 
164
164
  ```typescript
165
- export type WiredSingletonServices = RequiredSingletonServices & SingletonServices
166
- export type WiredServices = SecretlessServices<RequiredSingletonServices & Services>
165
+ export type WiredSingletonServices = RequiredSingletonServices &
166
+ SingletonServices
167
+ export type WiredServices = SecretlessServices<
168
+ RequiredSingletonServices & Services
169
+ >
167
170
  ```
168
171
 
169
172
  The `SecretlessServices<...>` wrapper is why `secrets` never appears in a
@@ -223,21 +226,21 @@ const createSingletonServices = pikkuServices(async (config) => {
223
226
 
224
227
  ### Built-in Services
225
228
 
226
- | Service | Package | Purpose |
227
- | -------------------------- | ---------------------- | -------------------------------- |
228
- | `ConsoleLogger` | `@pikku/core/services` | Console-based logging |
229
- | `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |
230
- | `LocalSecretService` | `@pikku/core/services` | Local development secrets |
231
- | `LocalVariablesService` | `@pikku/core/services` | Local environment variables |
232
- | `PinoLogger` | `@pikku/pino` | Structured logging via Pino |
233
- | `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |
234
- | `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |
229
+ | Service | Package | Purpose |
230
+ | ----------------------- | ---------------------- | --------------------------------------- |
231
+ | `ConsoleLogger` | `@pikku/core/services` | Console-based logging |
232
+ | `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |
233
+ | `LocalSecretService` | `@pikku/core/services` | Local development secrets |
234
+ | `LocalVariablesService` | `@pikku/core/services` | Local environment variables |
235
+ | `PinoLogger` | `@pikku/pino` | Structured logging via Pino |
236
+ | `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |
237
+ | `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |
235
238
 
236
239
  ## Complete Example
237
240
 
238
241
  ```typescript
239
242
  // services.ts
240
- import { pikkuServices, pikkuWireServices } from '#pikku'
243
+ import { pikkuServices, pikkuWireServices } from '#pikku/function'
241
244
  import { ConsoleLogger } from '@pikku/core/services'
242
245
  import { JoseJWTService } from '@pikku/jose'
243
246
 
@@ -249,9 +252,15 @@ class TodoStore {
249
252
  this.todos.set(todo.id, todo)
250
253
  return todo
251
254
  }
252
- async get(id: string) { return this.todos.get(id) }
253
- async list() { return [...this.todos.values()] }
254
- async delete(id: string) { this.todos.delete(id) }
255
+ async get(id: string) {
256
+ return this.todos.get(id)
257
+ }
258
+ async list() {
259
+ return [...this.todos.values()]
260
+ }
261
+ async delete(id: string) {
262
+ this.todos.delete(id)
263
+ }
255
264
  }
256
265
 
257
266
  export const createSingletonServices = pikkuServices(async (config) => {