@pikku/skills 0.12.4 → 0.12.8

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 (65) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +74 -33
  4. package/skills/pikku-ai-agent/SKILL.md +197 -105
  5. package/skills/pikku-ai-vercel/SKILL.md +57 -18
  6. package/skills/pikku-ai-voice/SKILL.md +126 -52
  7. package/skills/pikku-audit/SKILL.md +35 -13
  8. package/skills/pikku-aws/SKILL.md +66 -16
  9. package/skills/pikku-backblaze/SKILL.md +44 -11
  10. package/skills/pikku-better-auth/SKILL.md +45 -10
  11. package/skills/pikku-cli/SKILL.md +67 -18
  12. package/skills/pikku-cli/references/complete-example.md +2 -0
  13. package/skills/pikku-concepts/SKILL.md +75 -10
  14. package/skills/pikku-config/SKILL.md +56 -14
  15. package/skills/pikku-cron/SKILL.md +13 -6
  16. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  17. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  18. package/skills/pikku-deploy-express/SKILL.md +40 -4
  19. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  20. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  21. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  22. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  23. package/skills/pikku-deps/SKILL.md +29 -8
  24. package/skills/pikku-emails/SKILL.md +36 -5
  25. package/skills/pikku-fabric/SKILL.md +27 -2
  26. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  27. package/skills/pikku-feature/SKILL.md +6 -1
  28. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  29. package/skills/pikku-http/SKILL.md +18 -5
  30. package/skills/pikku-http/references/http-options.md +10 -5
  31. package/skills/pikku-i18n/SKILL.md +18 -7
  32. package/skills/pikku-info/SKILL.md +18 -8
  33. package/skills/pikku-jose/SKILL.md +35 -6
  34. package/skills/pikku-knowledge/SKILL.md +50 -7
  35. package/skills/pikku-kysely/SKILL.md +78 -15
  36. package/skills/pikku-machine-auth/SKILL.md +36 -1
  37. package/skills/pikku-mcp/SKILL.md +159 -149
  38. package/skills/pikku-middleware/SKILL.md +17 -5
  39. package/skills/pikku-mongodb/SKILL.md +10 -2
  40. package/skills/pikku-n8n-import/SKILL.md +14 -6
  41. package/skills/pikku-permissions/SKILL.md +102 -22
  42. package/skills/pikku-pino/SKILL.md +12 -4
  43. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  44. package/skills/pikku-queue/SKILL.md +45 -16
  45. package/skills/pikku-react/SKILL.md +41 -14
  46. package/skills/pikku-react-query/SKILL.md +14 -10
  47. package/skills/pikku-realtime/SKILL.md +44 -22
  48. package/skills/pikku-redis/SKILL.md +12 -3
  49. package/skills/pikku-rpc/SKILL.md +23 -12
  50. package/skills/pikku-rtl/SKILL.md +21 -17
  51. package/skills/pikku-scenario/SKILL.md +141 -76
  52. package/skills/pikku-schedule/SKILL.md +39 -6
  53. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  54. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  55. package/skills/pikku-security/SKILL.md +54 -9
  56. package/skills/pikku-services/SKILL.md +49 -9
  57. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  58. package/skills/pikku-template-clone/SKILL.md +10 -5
  59. package/skills/pikku-trigger/SKILL.md +50 -6
  60. package/skills/pikku-versioning/SKILL.md +46 -17
  61. package/skills/pikku-websocket/SKILL.md +72 -44
  62. package/skills/pikku-workflow/SKILL.md +123 -11
  63. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  64. package/skills/pikku-workflows-client/SKILL.md +13 -6
  65. package/skills/pikku-ws/SKILL.md +44 -8
@@ -6,7 +6,8 @@ description: >-
6
6
  generated WebSocket client. TRIGGER when: code uses wireChannel, user asks about WebSocket,
7
7
  real-time, live updates, chat, pub/sub, or the generated WebSocket client. DO NOT TRIGGER when:
8
8
  user asks about HTTP/REST (use pikku-http), SSE (use pikku-http with sse: true), or WebSocket
9
- deployment specifics (use pikku-deploy-uws).
9
+ deployment specifics (use pikku-deploy-uws), or typed pub/sub events (use pikku-realtime).
10
+ installGroups: [core]
10
11
  ---
11
12
 
12
13
  # Pikku WebSocket Wiring
@@ -41,19 +42,32 @@ import { wireChannel } from '@pikku/core/channel'
41
42
 
42
43
  wireChannel({
43
44
  name: string, // Channel name (e.g. 'todos')
44
- onConnect: async () => {}, // Called when client connects
45
- onDisconnect: async () => {}, // Called when client disconnects
46
- onMessageWiring: { // Action → function mapping
47
- [actionName: string]: {
48
- func: PikkuFunc,
49
- auth?: boolean, // Override channel-level auth
50
- permissions?: Record<string, PikkuPermission | PikkuPermission[]>,
45
+ route: string, // REQUIRED — the URL path (e.g. '/todos')
46
+ auth?: boolean, // Channel-level auth default
47
+ onConnect?: PikkuFunc, // Called when client connects
48
+ onDisconnect?: PikkuFunc, // Called when client disconnects
49
+ onMessage?: PikkuFunc, // Catch-all for unrouted messages
50
+ onMessageWiring?: { // TWO levels — see below
51
+ [messageField: string]: {
52
+ [fieldValue: string]: {
53
+ func: PikkuFunc,
54
+ auth?: boolean, // Override channel-level auth
55
+ middleware?: PikkuMiddleware[],
56
+ }
51
57
  }
52
58
  },
59
+ middleware?: PikkuMiddleware[],
53
60
  channelMiddleware?: PikkuChannelMiddleware[],
61
+ binary?: boolean | null,
62
+ onBinaryMessage?: (services, data, channel) => ...,
63
+ tags?: string[], // Targets tag middleware
54
64
  })
55
65
  ```
56
66
 
67
+ Note there is **no `permissions` key on a message wiring** — wire-level
68
+ permissions were removed in #972. Authorization lives on the function's own
69
+ `permissions` field (see `pikku-permissions`).
70
+
57
71
  ### `pikkuChannelMiddleware(fn)`
58
72
 
59
73
  ```typescript
@@ -78,37 +92,46 @@ addChannelMiddleware('todos', [addTimestamp, filterSensitive])
78
92
  ```typescript
79
93
  wireChannel({
80
94
  name: 'todos',
81
- onConnect: async () => {},
82
- onDisconnect: async () => {},
95
+ route: '/todos',
83
96
  onMessageWiring: {
84
- create: { func: createTodo },
85
- list: { func: listTodos, auth: false },
97
+ action: { // ← the field to route on
98
+ create: { func: createTodo }, // ← its possible values
99
+ list: { func: listTodos, auth: false },
100
+ },
86
101
  },
87
102
  })
88
103
  ```
89
104
 
90
105
  ### Action Routing with Auth
91
106
 
92
- Clients send `{ action: 'create', data: {...} }`. Pikku routes to the matching function.
107
+ `onMessageWiring` nests two levels because the routing key is configurable. The
108
+ **outer** key names the field in the incoming message to dispatch on; the
109
+ **inner** keys are the values that field can take. With the conventional outer
110
+ key `action`, a client sending `{ action: 'create', data: {...} }` reaches
111
+ `createTodo` — but a CLI channel might route on `command` instead, which is why
112
+ the field is not hardcoded.
93
113
 
94
114
  ```typescript
95
- const authenticate = pikkuFunc({
115
+ const authenticate = pikkuSessionlessFunc({
96
116
  title: 'Authenticate',
97
- func: async ({ setSession }, { token }) => {
117
+ // setSession lives on the WIRE (third param), not on services
118
+ func: async (services, { token }, { setSession }) => {
98
119
  const session = await verifyJWT(token)
99
- setSession(session)
120
+ await setSession(session)
100
121
  return { success: true }
101
122
  },
102
123
  })
103
124
 
104
125
  wireChannel({
105
126
  name: 'todos',
106
- onConnect: async () => {},
107
- onDisconnect: async () => {},
127
+ route: '/todos',
128
+ auth: true,
108
129
  onMessageWiring: {
109
- auth: { func: authenticate, auth: false }, // No session required
110
- subscribe: { func: subscribeTodos }, // Session required
111
- create: { func: createTodo },
130
+ action: {
131
+ authenticate: { func: authenticate, auth: false }, // No session required
132
+ subscribe: { func: subscribeTodos }, // Session required
133
+ create: { func: createTodo },
134
+ },
112
135
  },
113
136
  })
114
137
  ```
@@ -120,25 +143,28 @@ Use EventHub for real-time broadcasting across connections:
120
143
  ```typescript
121
144
  wireChannel({
122
145
  name: 'todos',
123
- onConnect: async ({ eventHub, channel }) => {
146
+ route: '/todos',
147
+ // eventHub is a service (1st param); channel lives on the wire (3rd)
148
+ onConnect: async ({ eventHub }, _data, { channel }) => {
124
149
  eventHub.subscribe('todos:updated', (data) => {
125
150
  channel.send(data)
126
151
  })
127
152
  },
128
- onDisconnect: async () => {},
129
153
  onMessageWiring: {
130
- create: {
131
- func: pikkuFunc({
132
- title: 'Create Todo',
133
- func: async ({ db, eventHub }, { text }) => {
134
- const todo = await db.createTodo({ text })
135
- eventHub.publish('todos:updated', {
136
- event: 'created',
137
- todo,
138
- })
139
- return { todo }
140
- },
141
- }),
154
+ action: {
155
+ create: {
156
+ func: pikkuFunc({
157
+ title: 'Create Todo',
158
+ func: async ({ db, eventHub }, { text }) => {
159
+ const todo = await db.createTodo({ text })
160
+ eventHub.publish('todos:updated', {
161
+ event: 'created',
162
+ todo,
163
+ })
164
+ return { todo }
165
+ },
166
+ }),
167
+ },
142
168
  },
143
169
  },
144
170
  })
@@ -167,9 +193,8 @@ addChannelMiddleware('todos', [addTimestamp, filterSensitive])
167
193
  // Or inline on wiring
168
194
  wireChannel({
169
195
  name: 'todos',
196
+ route: '/todos',
170
197
  channelMiddleware: [addTimestamp],
171
- onConnect: async () => {},
172
- onDisconnect: async () => {},
173
198
  onMessageWiring: { ... },
174
199
  })
175
200
  ```
@@ -179,7 +204,7 @@ wireChannel({
179
204
  After `npx pikku all`:
180
205
 
181
206
  ```typescript
182
- import { PikkuWebSocket } from '.pikku/pikku-websocket.gen.js'
207
+ import { PikkuWebSocket } from '#pikku/pikku-websocket.gen.js'
183
208
 
184
209
  const pikku = new PikkuWebSocket(ws)
185
210
  const todosRoute = pikku.getRoute('todos')
@@ -197,7 +222,7 @@ todosRoute.subscribe('todos:updated', (data) => {
197
222
 
198
223
  ```typescript
199
224
  // functions/chat.functions.ts
200
- export const authenticate = pikkuFunc({
225
+ export const authenticate = pikkuSessionlessFunc({
201
226
  title: 'Authenticate',
202
227
  func: async ({ jwt }, { token }, { setSession }) => {
203
228
  const payload = await jwt.verify(token)
@@ -228,16 +253,19 @@ export const listMessages = pikkuSessionlessFunc({
228
253
  // wirings/chat.channel.ts
229
254
  wireChannel({
230
255
  name: 'chat',
231
- onConnect: async ({ eventHub, channel }) => {
256
+ route: '/chat',
257
+ auth: true,
258
+ onConnect: async ({ eventHub }, _data, { channel }) => {
232
259
  eventHub.subscribe('chat:message', (data) => {
233
260
  channel.send(data)
234
261
  })
235
262
  },
236
- onDisconnect: async () => {},
237
263
  onMessageWiring: {
238
- auth: { func: authenticate, auth: false },
239
- send: { func: sendMessage },
240
- history: { func: listMessages, auth: false },
264
+ action: {
265
+ authenticate: { func: authenticate, auth: false },
266
+ send: { func: sendMessage },
267
+ history: { func: listMessages, auth: false },
268
+ },
241
269
  },
242
270
  })
243
271
  ```
@@ -29,11 +29,11 @@ Build durable, multi-step workflows with automatic retry, sleep, suspend/resume,
29
29
 
30
30
  ## Choosing the right factory
31
31
 
32
- | Factory | When to use | Step-graph view? |
33
- |---|---|---|
34
- | `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 |
35
- | `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |
36
- | `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) |
32
+ | Factory | When to use | Step-graph view? |
33
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
34
+ | `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 |
35
+ | `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |
36
+ | `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) |
37
37
 
38
38
  **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.
39
39
 
@@ -58,7 +58,11 @@ if (priority === 'high') {
58
58
 
59
59
  ```typescript
60
60
  // CORRECT — workflow factories come from the generated types file
61
- import { pikkuWorkflowFunc, pikkuWorkflowGraph, pikkuWorkflowComplexFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'
61
+ import {
62
+ pikkuWorkflowFunc,
63
+ pikkuWorkflowGraph,
64
+ pikkuWorkflowComplexFunc,
65
+ } from '#pikku/workflow/pikku-workflow-types.gen.js'
62
66
 
63
67
  // WRONG — '#pikku' does not re-export them (TS2305)
64
68
  import { pikkuWorkflowFunc } from '#pikku'
@@ -73,7 +77,10 @@ import { z } from 'zod'
73
77
  import { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'
74
78
 
75
79
  const ProcessOrderInput = z.object({ orderId: z.string(), amount: z.number() })
76
- const ProcessOrderOutput = z.object({ status: z.string(), discount: z.number().optional() })
80
+ const ProcessOrderOutput = z.object({
81
+ status: z.string(),
82
+ discount: z.number().optional(),
83
+ })
77
84
 
78
85
  export const processOrder = pikkuWorkflowFunc({
79
86
  description: 'Process an order through payment and fulfillment',
@@ -86,7 +93,9 @@ export const processOrder = pikkuWorkflowFunc({
86
93
  let status: string
87
94
 
88
95
  if (data.amount > 1000) {
89
- const d = await workflow.do('Apply bulk discount', 'calcDiscount', { amount: data.amount })
96
+ const d = await workflow.do('Apply bulk discount', 'calcDiscount', {
97
+ amount: data.amount,
98
+ })
90
99
  discount = d.discountPercent
91
100
  }
92
101
 
@@ -121,13 +130,113 @@ await workflow.sleep('Wait 5 minutes', '5min')
121
130
 
122
131
  // Suspend — pause until externally resumed (e.g. awaiting approval), then continue
123
132
  await workflow.suspend('Awaiting approval')
133
+
134
+ // Approval — suspend for a human decision and resume with the answer
135
+ await workflow.approval('Manager sign-off', { ... })
136
+ ```
137
+
138
+ `workflow.name`, `workflow.runId` and `await workflow.getRun()` identify the
139
+ current run if a step needs to reference it.
140
+
141
+ ### Approval gates: who may answer
142
+
143
+ `workflow.approval(reason, options)` takes a `schema` (a runtime value — the
144
+ payload arrives from an untrusted caller, so a type generic would validate
145
+ nothing), an optional `expiry`, and an optional policy for **who** may answer:
146
+
147
+ ```typescript
148
+ const signOff = await workflow.approval('Manager sign-off', {
149
+ schema: SignOffSchema,
150
+ expiry: '3d',
151
+ approvers: 'not-initiator', // four-eyes: anyone but whoever started the run
152
+ approverScope: 'payments:approve', // and they must hold this scope
153
+ })
154
+ if (signOff.status === 'expired') { ... }
124
155
  ```
125
156
 
157
+ `approvers` is one of:
158
+
159
+ | value | who may answer |
160
+ | ----------------- | -------------------------------------------------------------------------------------------------------- |
161
+ | `any` _(default)_ | anyone the approve entrypoint admits — the gate is a pause for a decision, not an authorization boundary |
162
+ | `owner` | only the user who started the run |
163
+ | `not-initiator` | anyone **except** the user who started the run |
164
+
165
+ Both options are enforced in two phases, because a decision can legitimately
166
+ arrive before the run has reached the gate:
167
+
168
+ - **At submission**, if the run has already reached the gate. Reaching it
169
+ publishes the policy into the run state, so the approve entrypoint can judge
170
+ the caller against it and refuse with a **403**.
171
+ - **On replay**, for a decision that arrived before the gate — there was no
172
+ policy to judge it against yet, so it is accepted and judged when the workflow
173
+ reaches the gate. Failing there discards the decision and leaves the gate
174
+ closed, exactly as a decision that fails the schema does.
175
+
176
+ So the same rejected decision surfaces as an HTTP error or as a silently
177
+ re-closed gate depending on timing. Both are audited.
178
+
179
+ A gate declaring neither option accepts a decision from anyone the approve
180
+ route lets through; gate the route with `auth`/`permissions` to narrow that.
181
+
182
+ #### What survives the run
183
+
184
+ A settled decision carries `decidedBy` and `decidedAt`, so the answer keeps its
185
+ provenance in the step result:
186
+
187
+ ```typescript
188
+ if (signOff.status === 'decided') {
189
+ logger.info(`signed by ${signOff.decidedBy?.userId} at ${signOff.decidedAt}`)
190
+ }
191
+ ```
192
+
193
+ That record is deleted with the run, though — `deleteRun` cascades to steps and
194
+ history — and an attempt that was _refused_ never reaches a step at all. So
195
+ every answer is also written to the audit sink as `workflow.approval.decided`,
196
+ with `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the
197
+ run, reason and refusal in `metadata`. Wire an `audit` service to keep it; a
198
+ project without one records nothing and is otherwise unaffected.
199
+
200
+ ### Error handling: `onError`, never try/catch
201
+
202
+ **Do not wrap steps in try/catch.** The DSL extractor serialises the body into a
203
+ step graph, and a `catch` block is control flow it cannot represent — so the
204
+ graph would no longer describe what actually runs, which is the whole point of
205
+ the DSL mode. This is a settled design decision, not a temporary limitation.
206
+
207
+ Use the `onError` step option instead: it names an RPC to invoke when the step
208
+ has failed _after_ exhausting its retries.
209
+
210
+ ```typescript
211
+ await workflow.do(
212
+ 'Charge',
213
+ 'chargePayment',
214
+ { orderId },
215
+ {
216
+ retries: 3,
217
+ retryDelay: '1s',
218
+ onError: 'refundReservation', // compensation RPC
219
+ }
220
+ )
221
+ ```
222
+
223
+ The handler receives `{ error: { message } }`, and the original error is still
224
+ thrown afterwards — so the workflow still fails. `onError` is **compensation, not
225
+ recovery**: it exists to undo work, not to swallow the failure and carry on. If
226
+ you genuinely need to branch on a failure, have the step return a result object
227
+ (`{ success: false, reason }`) and branch on that, the way the `processOrder`
228
+ example branches on `payment.success`.
229
+
230
+ Full step options: `description`, `retries`, `retryDelay`, `onError` (plus
231
+ `actor`, which is scenario-only — see `pikku-scenario`).
232
+
126
233
  ### Parallel fan-out
127
234
 
128
235
  ```typescript
129
236
  const users = await Promise.all(
130
- data.userIds.map((userId) => workflow.do(`Fetch user ${userId}`, 'getUser', { userId }))
237
+ data.userIds.map((userId) =>
238
+ workflow.do(`Fetch user ${userId}`, 'getUser', { userId })
239
+ )
131
240
  )
132
241
  ```
133
242
 
@@ -148,7 +257,10 @@ export const userOnboarding = pikkuWorkflowGraph({
148
257
  config: {
149
258
  createProfile: { next: ['sendWelcome', 'setupDefaults'] }, // run in parallel
150
259
  sendWelcome: {
151
- input: (ref) => ({ to: ref('createProfile', 'email'), subject: 'Welcome!' }),
260
+ input: (ref) => ({
261
+ to: ref('createProfile', 'email'),
262
+ subject: 'Welcome!',
263
+ }),
152
264
  },
153
265
  },
154
266
  })
@@ -161,7 +273,7 @@ export const userOnboarding = pikkuWorkflowGraph({
161
273
 
162
274
  ## Step dispatch & HTTP wiring
163
275
 
164
- For per-step inline-vs-queue dispatch (`inline: false` and the `dispatchStep` rules), the manual `workflowStart`/`workflow`/`workflowStatus` HTTP wirings, and a suspend/resume example, read `references/workflow-reference.md`.
276
+ For 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`.
165
277
 
166
278
  ## After writing
167
279
 
@@ -2,31 +2,36 @@
2
2
 
3
3
  ## Step execution: inline vs queue dispatch
4
4
 
5
- Whether 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 `inline` flag. `workflow.do(...)` options are only `retries`/`retryDelay`/`description`.
5
+ Whether 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`.
6
6
 
7
7
  - **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.
8
- - **`inline: false` opts a function out.** Set `inline: false` 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.
8
+ - **`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.
9
9
  - **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.
10
10
 
11
11
  The rule (`dispatchStep`):
12
12
 
13
- | Function `inline` | `queueService` present? | Result |
13
+ | Function `workflowQueued` | `queueService` present? | Result |
14
14
  |---|---|---|
15
- | default / `true` | any | **inline** |
16
- | `false` | yes | **queued** (own worker) |
17
- | `false` | no | **inline + a `logger.warn`** (misconfiguration: can't dispatch) |
15
+ | default / `false` | any | **inline** |
16
+ | `true` | yes | **queued** (own worker) |
17
+ | `true` | no | **throws** |
18
18
 
19
19
  ```typescript
20
20
  // Push this one expensive step onto the queue; every other step stays inline:
21
21
  export const renderLargeReport = pikkuSessionlessFunc({
22
- inline: false, // dispatch via queue instead of running inline
22
+ workflowQueued: true, // dispatch via queue instead of running inline
23
+ workflowRetries: 3,
24
+ workflowTimeout: '5m',
23
25
  input: ReportInput,
24
26
  output: ReportOutput,
25
27
  func: async (services, data) => { /* ... */ },
26
28
  })
27
29
  ```
28
30
 
29
- `inline: false` requires a `queueService`; without one the step still runs (so the workflow progresses) but emits a `logger.warn` so the misconfiguration is visible.
31
+ `workflowQueued: true` **requires** a `queueService`. Without one the step throws
32
+ rather than quietly running inline — a step marked for its own worker usually
33
+ carries timeout and concurrency expectations that inline execution would silently
34
+ violate, so failing loudly is safer than proceeding.
30
35
 
31
36
  ## HTTP workflow wiring (manual)
32
37
 
@@ -16,8 +16,8 @@ Use this skill as an execution checklist, not reference material.
16
16
  4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.
17
17
  5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.
18
18
 
19
- When a project has `pikkuWorkflowGraph` workflows, three React Query
20
- hooks are auto-generated alongside the standard RPC hooks. They handle
19
+ When a project defines any workflow — DSL or `pikkuWorkflowGraph` — three
20
+ React Query hooks are auto-generated alongside the standard RPC hooks. They handle
21
21
  the two common shapes: **run-and-wait** (short workflows where the
22
22
  client waits for the result) and **fire-and-poll** (long workflows where
23
23
  the client gets a `runId` and polls status).
@@ -103,8 +103,14 @@ function VideoStatus({ runId }: { runId: string }) {
103
103
 
104
104
  Status values: `'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'`.
105
105
 
106
- The hook stops auto-polling when the run reaches a terminal state (set
107
- `refetchInterval` to false in those cases — pattern shown above).
106
+ **Stopping the poll is your job.** The hook adds no terminal-state logic of its
107
+ own — the `refetchInterval` callback above is what ends it, by returning `false`
108
+ once `status` is no longer `running`. Leave that out and a finished run keeps
109
+ being polled forever.
110
+
111
+ The hook is disabled until `runId` is set, so passing `undefined` while the run
112
+ has not started yet is the intended shape rather than something to guard around.
113
+ That `enabled` is owned by the hook and cannot be overridden through `options`.
108
114
 
109
115
  ## Putting it together — start + observe
110
116
 
@@ -140,8 +146,9 @@ docs).
140
146
 
141
147
  ## What NOT to do
142
148
 
143
- - Don't poll status manually — use `useWorkflowStatus` with
144
- `refetchInterval`. It dedupes and stops on terminal states.
149
+ - Don't poll status manually with `setInterval` — use `useWorkflowStatus`
150
+ with a `refetchInterval` callback, which dedupes across components and
151
+ lets you stop on a terminal state in one place.
145
152
  - Don't call `useRunWorkflow` for workflows that take more than a few
146
153
  seconds. The user-facing component will hold a long-running pending
147
154
  state with no progress indication; use start + status instead.
@@ -31,17 +31,53 @@ yarn add @pikku/ws ws
31
31
 
32
32
  ### Basic Setup
33
33
 
34
+ The package exports one function, `pikkuWebsocketHandler` — there is no server
35
+ class. You own the `http.Server` and the `WebSocketServer`; the handler attaches
36
+ the upgrade and message plumbing to them.
37
+
34
38
  ```typescript
35
- import { PikkuWSServer } from '@pikku/ws'
39
+ import { pikkuWebsocketHandler } from '@pikku/ws'
40
+ import { stopSingletonServices } from '@pikku/core'
41
+ import { Server } from 'http'
42
+ import { WebSocketServer } from 'ws'
43
+
44
+ import '../.pikku/pikku-bootstrap.gen.js'
45
+ import { createConfig, createSingletonServices } from './services.js'
46
+
47
+ const config = await createConfig()
48
+ const singletonServices = await createSingletonServices(config)
49
+
50
+ const server = new Server()
51
+ const wss = new WebSocketServer({ noServer: true })
36
52
 
37
- const wsServer = new PikkuWSServer({
38
- server: httpServer, // Node.js HTTP server
39
- singletonServices,
40
- createWireServices,
41
- channelStore,
53
+ pikkuWebsocketHandler({
54
+ server,
55
+ wss,
56
+ logger: singletonServices.logger,
57
+ logRoutes: true, // print the wired channels at startup
58
+ loadSchemas: true, // compile input schemas up front
42
59
  })
43
60
 
44
- await wsServer.init()
61
+ server.listen(4002, 'localhost')
45
62
  ```
46
63
 
47
- This runtime bridges the `ws` WebSocket library with Pikku's channel wiring. See `pikku-websocket` for channel wiring details and `pikku-deploy-fastify`/`pikku-deploy-express` for integrating with HTTP servers.
64
+ `noServer: true` is not optional decoration — the handler performs the upgrade
65
+ itself so it can run pikku's HTTP middleware chain (auth, cors) against the
66
+ upgrade request before a channel exists. Letting `ws` bind the server directly
67
+ would skip that.
68
+
69
+ Services come from the bootstrap import and the global singleton registry, which
70
+ is why nothing is passed in. The event hub is taken from
71
+ `singletonServices.eventHub` when it is a `LocalEventHubService`, and a local one
72
+ is created otherwise — so a single-process app gets pub/sub for free, while a
73
+ multi-instance deployment must register a distributed hub (see `pikku-realtime`).
74
+
75
+ The options type also extends `RunHTTPWiringOptions`, so per-request settings
76
+ such as `respondWith404`, `coerceDataFromSchema` and `bubbleErrors` are accepted
77
+ here too.
78
+
79
+ On shutdown, call `stopSingletonServices()` then close `wss` and `server`.
80
+
81
+ See `pikku-websocket` for channel wiring details, and
82
+ `pikku-deploy-fastify`/`pikku-deploy-express` when the WebSocket server shares a
83
+ port with an HTTP app.