@pikku/skills 0.12.10 → 0.12.12

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 (61) hide show
  1. package/CHANGELOG.md +819 -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 +4 -5
  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 +28 -7
  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 +52 -21
  46. package/skills/pikku-rpc/SKILL.md +4 -2
  47. package/skills/pikku-rtl/SKILL.md +1 -1
  48. package/skills/pikku-scenario/SKILL.md +131 -20
  49. package/skills/pikku-schedule/SKILL.md +6 -1
  50. package/skills/pikku-schema-ajv/SKILL.md +2 -2
  51. package/skills/pikku-schema-cfworker/SKILL.md +1 -1
  52. package/skills/pikku-security/SKILL.md +9 -5
  53. package/skills/pikku-services/SKILL.md +27 -18
  54. package/skills/pikku-services/references/audit-wire-service.md +14 -8
  55. package/skills/pikku-software-archaeology/scripts/validate.mjs +146 -69
  56. package/skills/pikku-tag-middleware/SKILL.md +1 -0
  57. package/skills/pikku-template-clone/SKILL.md +2 -1
  58. package/skills/pikku-trigger/SKILL.md +3 -3
  59. package/skills/pikku-websocket/SKILL.md +4 -3
  60. package/skills/pikku-workflow/SKILL.md +2 -2
  61. package/skills/pikku-workflow/references/workflow-reference.md +24 -10
@@ -6,7 +6,8 @@ reader. Delete any section that would be empty rather than padding it.
6
6
  ---
7
7
 
8
8
  # {App name}, in plain English — and where it could get better
9
- *A second opinion on {scope: the whole app / the competitor-tracking system / …}*
9
+
10
+ _A second opinion on {scope: the whole app / the competitor-tracking system / …}_
10
11
 
11
12
  **How to read this:** no technical background needed. Part 1 is the summary — if
12
13
  you read nothing else, read that. Parts 2–3 go area by area for anyone who wants
@@ -21,9 +22,9 @@ opportunities. No jargon.}
21
22
 
22
23
  **If it were me, this is the order I'd tackle things:**
23
24
 
24
- | Fix | Why it matters to you | Effort | Payoff |
25
- |---|---|---|---|
26
- | {…} | {business impact} | Small/Medium/Large | High/Medium/Low |
25
+ | Fix | Why it matters to you | Effort | Payoff |
26
+ | --- | --------------------- | ------------------ | --------------- |
27
+ | {…} | {business impact} | Small/Medium/Large | High/Medium/Low |
27
28
 
28
29
  ---
29
30
 
@@ -38,6 +39,7 @@ opportunities. No jargon.}
38
39
  **What's working.** {genuine credit — the parts that are solid and worth keeping}
39
40
 
40
41
  **What's holding you back.**
42
+
41
43
  - **{Problem in plain terms}.** What it means for you: {business impact}.
42
44
  Severity: {Minor / Worth fixing / Serious / Urgent}. Effort to fix: {Small /
43
45
  Medium / Large}.
@@ -54,11 +56,12 @@ off. Say whether it's a cheap rewire or an expensive rebuild.}
54
56
  libraries — AND anything a rebuild would move them ONTO. Each gets both sides.}
55
57
 
56
58
  **{Technology}.**
57
- - *Buys you:* {in business terms}
58
- - *Costs you:* {in business terms — bills, hiring, shipping speed, upgrade work,
59
+
60
+ - _Buys you:_ {in business terms}
61
+ - _Costs you:_ {in business terms — bills, hiring, shipping speed, upgrade work,
59
62
  the risk of betting on something young. Don't soften it. If it hasn't shipped a
60
63
  stable 1.0, say so and say what that means: pin the version, budget upgrades.}
61
- - *Usually:* {recommendation tied to their stage — normally "keep it, watch this"}
64
+ - _Usually:_ {recommendation tied to their stage — normally "keep it, watch this"}
62
65
 
63
66
  {The same bar applies to anything you're recommending they move to. A stack you
64
67
  propose with no cons listed is a pitch, not a second opinion.}
@@ -63,7 +63,7 @@ Not every adapter supports every option. Each adapter declares a
63
63
  silently ignored — so check the startup logs if a setting appears to have no
64
64
  effect.
65
65
 
66
- `groupConcurrency` limits how many jobs run concurrently *per group* (jobs
66
+ `groupConcurrency` limits how many jobs run concurrently _per group_ (jobs
67
67
  carrying a `JobGroup` with an `id` and optional `tier`), so one noisy tenant
68
68
  cannot consume the whole worker:
69
69
 
@@ -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`
@@ -66,8 +66,7 @@ see `pikku-services`.
66
66
  ## 2. Enable the server side
67
67
 
68
68
  ```bash
69
- yarn pikku enable events # auth required by default
70
- yarn pikku enable events --noAuth # public events
69
+ yarn pikku enable events
71
70
  ```
72
71
 
73
72
  This sets `scaffold.events` in `pikku.config.json`. The next `pikku all` generates
@@ -97,19 +96,38 @@ a one-word change, not a different import:
97
96
 
98
97
  ```ts
99
98
  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
99
+ constructor(options?: {
100
+ reconnect?: boolean
101
+ reconnectDelayMs?: number
102
+ reconnectMaxDelayMs?: number
103
+ })
104
+ setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
102
105
 
103
106
  // 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
107
+ subscribe<K extends keyof EventHubTopics>(
108
+ topic: K,
109
+ handler: (data: EventHubTopics[K]) => void
110
+ ): () => void
111
+ unsubscribe<K extends keyof EventHubTopics>(
112
+ topic: K,
113
+ handler?: (data: EventHubTopics[K]) => void
114
+ ): void
106
115
 
107
116
  // SSE at GET /events/:topic — one EventSource per topic
108
- subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }
117
+ subscribeToTopic<K extends keyof EventHubTopics>(
118
+ topic: K,
119
+ handler: (data: EventHubTopics[K]) => void
120
+ ): { close: () => void }
109
121
 
110
122
  // 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
123
+ subscribeToSSE<T>(
124
+ path: string,
125
+ handler: (data: T) => void
126
+ ): { close: () => void }
127
+ connectToChannel(
128
+ channelRoute: string,
129
+ protocols?: string | string[]
130
+ ): WebSocket
113
131
 
114
132
  close(): void
115
133
  }
@@ -137,14 +155,16 @@ Envelope the payload as `{ topic, data }`: the generated client dispatches on th
137
155
  `topic` field, so a bare payload arrives but no handler fires.
138
156
 
139
157
  ```ts
140
- import { pikkuFunc } from '#pikku'
158
+ import { pikkuFunc } from '#pikku/function'
141
159
 
142
160
  export const createTodo = pikkuFunc({
143
161
  input: CreateTodoInput,
144
162
  output: CreateTodoOutput,
145
163
  func: async ({ kysely, eventHub }, data) => {
146
164
  const todo = await kysely
147
- .insertInto('todos').values(data).returningAll()
165
+ .insertInto('todos')
166
+ .values(data)
167
+ .returningAll()
148
168
  .executeTakeFirstOrThrow()
149
169
 
150
170
  await eventHub.publish('todo-created', null, {
@@ -160,7 +180,9 @@ A thin helper removes the duplication:
160
180
 
161
181
  ```ts
162
182
  async function publishEvent<K extends keyof EventHubTopics>(
163
- hub: EventHubService<EventHubTopics>, topic: K, data: EventHubTopics[K]
183
+ hub: EventHubService<EventHubTopics>,
184
+ topic: K,
185
+ data: EventHubTopics[K]
164
186
  ) {
165
187
  return hub.publish(topic, null, { topic, data })
166
188
  }
@@ -187,7 +209,9 @@ const pikku = createPikku(
187
209
  // pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.
188
210
 
189
211
  createRoot(document.getElementById('root')!).render(
190
- <PikkuProvider pikku={pikku}><App /></PikkuProvider>
212
+ <PikkuProvider pikku={pikku}>
213
+ <App />
214
+ </PikkuProvider>
191
215
  )
192
216
  ```
193
217
 
@@ -214,7 +238,8 @@ function TodoList() {
214
238
  useEffect(() => {
215
239
  // WebSocket multi-topic:
216
240
  const off = realtime.subscribe('todo-created', ({ todo }) =>
217
- setTodos((prev) => [...prev, todo]))
241
+ setTodos((prev) => [...prev, todo])
242
+ )
218
243
  return off
219
244
 
220
245
  // Single-topic SSE (auto-cleanup on close) instead:
@@ -223,7 +248,13 @@ function TodoList() {
223
248
  // return () => sub.close()
224
249
  }, [realtime])
225
250
 
226
- return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
251
+ return (
252
+ <ul>
253
+ {todos.map((t) => (
254
+ <li key={t.id}>{t.title}</li>
255
+ ))}
256
+ </ul>
257
+ )
227
258
  }
228
259
  ```
229
260
 
@@ -235,12 +266,12 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
235
266
 
236
267
  ## When to pick which transport
237
268
 
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` |
269
+ | Need | Use |
270
+ | ------------------------------------------ | --------------------------- |
271
+ | Many topics in one connection | `realtime.subscribe` |
272
+ | Single live stream, simple cleanup | `realtime.subscribeToTopic` |
273
+ | Bidirectional (client also sends messages) | `realtime.subscribe` |
274
+ | WebSockets blocked by infra | `realtime.subscribeToTopic` |
244
275
 
245
276
  Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
246
277
  all topics for the dead channel id). Don't write manual cleanup unless you're
@@ -75,10 +75,12 @@ The `POST /rpc/:rpcName` endpoint that dispatches every `expose: true` function
75
75
  is **generated, not hand-written**. Turn it on and let codegen own it:
76
76
 
77
77
  ```bash
78
- pikku enable rpc # sets scaffold.rpc = true (auth required)
79
- pikku enable rpc --noAuth # sets scaffold.rpc = { auth: false } (public)
78
+ pikku enable rpc # sets scaffold.rpc = true
80
79
  ```
81
80
 
81
+ The flag says the endpoint exists, not who may call it — each exposed function
82
+ is gated by its own `auth`, permissions and scopes.
83
+
82
84
  This writes `rpc-public.gen.ts` with an `rpcCaller` function and its `wireHTTP`
83
85
  call already wired. Do not write that wiring yourself — a hand-rolled copy
84
86
  collides with the generated route on the same path.
@@ -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
@@ -568,14 +682,13 @@ Coverage is attributed by running scenarios against a server that is collecting
568
682
  Prerequisite in `pikku.config.json`:
569
683
 
570
684
  ```bash
571
- pikku enable scenarios # sets scaffold.scenarios = true (session required)
572
- pikku enable scenarios --noAuth # sets scaffold.scenarios = { "auth": false }
685
+ pikku enable scenarios # sets scaffold.scenarios = true
573
686
  ```
574
687
 
575
- `scaffold.scenarios` is a boolean or `{ auth?, path? }`. The legacy string forms
576
- (`"auth"` / `"no-auth"`) are **rejected by the config loader**, not reinterpreted —
577
- under a shape where a string could be a path, silently reading one as a flag
578
- would be worse than failing.
688
+ `scaffold.scenarios` is a boolean or `{ path? }` — whether the surface exists
689
+ and where it is written. A bare string is **rejected by the config loader**, not
690
+ reinterpreted: under a shape where a string could be a path, silently reading
691
+ one as a flag would be worse than failing.
579
692
 
580
693
  `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`.
581
694
 
@@ -592,9 +705,7 @@ The run resets coverage before each scenario and snapshots after, writing **`<ou
592
705
  "generatedAt": "…",
593
706
  "environment": "local",
594
707
  "scenarios": {
595
- "<name>": {
596
- /* FunctionCoverageReport */
597
- },
708
+ "<name>": {/* FunctionCoverageReport */},
598
709
  },
599
710
  }
600
711
  ```
@@ -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({