@pikku/skills 0.12.2 → 0.12.6

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 (68) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +56 -29
  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 +80 -34
  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 +82 -10
  14. package/skills/pikku-concepts/references/concept-mapping.md +2 -2
  15. package/skills/pikku-config/SKILL.md +134 -52
  16. package/skills/pikku-cron/SKILL.md +13 -6
  17. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  18. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  19. package/skills/pikku-deploy-express/SKILL.md +40 -4
  20. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  21. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  22. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  23. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  24. package/skills/pikku-deps/SKILL.md +29 -8
  25. package/skills/pikku-emails/SKILL.md +36 -5
  26. package/skills/pikku-fabric/SKILL.md +30 -5
  27. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  28. package/skills/pikku-feature/SKILL.md +12 -7
  29. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  30. package/skills/pikku-http/SKILL.md +18 -5
  31. package/skills/pikku-http/references/http-options.md +10 -5
  32. package/skills/pikku-i18n/SKILL.md +18 -7
  33. package/skills/pikku-info/SKILL.md +18 -8
  34. package/skills/pikku-jose/SKILL.md +35 -6
  35. package/skills/pikku-knowledge/SKILL.md +3 -3
  36. package/skills/pikku-kysely/SKILL.md +78 -15
  37. package/skills/pikku-machine-auth/SKILL.md +36 -1
  38. package/skills/pikku-mcp/SKILL.md +159 -149
  39. package/skills/pikku-middleware/SKILL.md +17 -5
  40. package/skills/pikku-mongodb/SKILL.md +10 -2
  41. package/skills/pikku-n8n-import/SKILL.md +14 -6
  42. package/skills/pikku-permissions/SKILL.md +102 -22
  43. package/skills/pikku-pino/SKILL.md +12 -4
  44. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  45. package/skills/pikku-queue/SKILL.md +45 -16
  46. package/skills/pikku-react/SKILL.md +41 -14
  47. package/skills/pikku-react-query/SKILL.md +14 -10
  48. package/skills/pikku-realtime/SKILL.md +44 -22
  49. package/skills/pikku-redis/SKILL.md +12 -3
  50. package/skills/pikku-rpc/SKILL.md +23 -12
  51. package/skills/pikku-rtl/SKILL.md +21 -17
  52. package/skills/pikku-scenario/SKILL.md +285 -50
  53. package/skills/pikku-schedule/SKILL.md +39 -6
  54. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  55. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  56. package/skills/pikku-security/SKILL.md +54 -9
  57. package/skills/pikku-services/SKILL.md +49 -9
  58. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  59. package/skills/pikku-software-archaeology/README.md +16 -6
  60. package/skills/pikku-software-archaeology/references/pikku-mapping.md +30 -30
  61. package/skills/pikku-template-clone/SKILL.md +10 -5
  62. package/skills/pikku-trigger/SKILL.md +50 -6
  63. package/skills/pikku-versioning/SKILL.md +46 -17
  64. package/skills/pikku-websocket/SKILL.md +72 -44
  65. package/skills/pikku-workflow/SKILL.md +35 -1
  66. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  67. package/skills/pikku-workflows-client/SKILL.md +13 -6
  68. package/skills/pikku-ws/SKILL.md +44 -8
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  name: pikku-permissions
3
3
  description: >-
4
- Use when adding authorization checks to Pikku functions — pikkuPermission, pikkuAuth,
5
- per-function permissions, global permissions, or understanding OR/AND permission logic.
6
- TRIGGER when: user wants to restrict who can call a function, check resource ownership, add
7
- role-based access, or understand where permission checks belong. DO NOT TRIGGER when: user asks
8
- about middleware or request interception (use pikku-middleware), authentication strategies (use
4
+ Use when adding authorization checks to Pikku functions — pikkuPermission, pikkuAuth, scopes and
5
+ defineScope, per-function permissions, global permissions, or understanding the scope/OR/AND
6
+ gating logic. TRIGGER when: user wants to restrict who can call a function, check resource
7
+ ownership, add role-based or scope-based access, declares or grants scopes, hits
8
+ MissingScopeError, or asks where permission checks belong. DO NOT TRIGGER when: user asks about
9
+ middleware or request interception (use pikku-middleware), authentication strategies (use
9
10
  pikku-security), or session management.
10
11
  installGroups: [core]
11
12
  ---
@@ -16,7 +17,7 @@ installGroups: [core]
16
17
 
17
18
  **ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**
18
19
 
19
- This includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func`, is visible to the inspector, and is the only place Pikku enforces authorization.
20
+ This includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func` and is visible to the inspector, so the gate is declared rather than buried — which is what lets `pikku info permissions` and an audit see it at all. Alongside it sits `scopes` (see below) for grant-based gating; between them they are where Pikku enforces authorization. The one sanctioned exception is `permissionsInBody`, covered at the end.
20
21
 
21
22
  ```typescript
22
23
  // CORRECT
@@ -104,11 +105,11 @@ export const hasBookAccess = pikkuPermission(
104
105
 
105
106
  ```typescript
106
107
  permissions: {
107
- verified: isVerified, // OR: verified users can access
108
- owner: isBookOwner, // OR: owners can access
109
- reviewer: [isAuthenticated, hasBookAccess], // AND: both must pass
108
+ verified: isVerified, // OR: verified users can access
109
+ owner: isBookOwner, // OR: owners can access
110
+ reviewer: [isVerified, hasBookAccess], // AND: both must pass
110
111
  }
111
- // Logic: verified OR owner OR (isAuthenticated AND hasBookAccess)
112
+ // Logic: verified OR owner OR (isVerified AND hasBookAccess)
112
113
  ```
113
114
 
114
115
  Groups are OR'd. Entries within a group array are AND'd.
@@ -134,23 +135,106 @@ export const deleteBook = pikkuFunc({
134
135
  A global permission is an app-wide baseline that **every** function must additionally pass. It is an independent AND gate: it can only ever *narrow* access — it never grants access a function's own `permissions` would deny.
135
136
 
136
137
  ```typescript
137
- import { addGlobalPermission } from '.pikku/pikku-types.gen.js'
138
+ import { addGlobalPermission } from '#pikku'
138
139
 
139
- addGlobalPermission([signedInUser]) // every function now also requires a session
140
+ addGlobalPermission([isEmployee]) // every function now also requires an employee session
140
141
  ```
141
142
 
142
143
  Multiple `addGlobalPermission` calls accumulate and are AND'd together.
143
144
 
144
145
  > Wire-, tag-, and HTTP-route-level permissions (`addHTTPPermission`, `addTagPermission`, and a `permissions` field on HTTP/channel/MCP wirings) were **removed in #972**. Permissions now live only on the function definition, plus the optional global gate. Tags are organizational only — use tag/HTTP *middleware* (`addTagMiddleware`, `addHTTPMiddleware`) for cross-cutting request handling, not authorization.
145
146
 
146
- ## The Two Gates
147
+ ## Scopes — the AND Gate Above Permissions
147
148
 
148
- Authorization is two independent gates, both of which must pass:
149
+ Scopes answer "what was this session granted?" before permissions ask "may this
150
+ user do this to this resource?". They are AND-ed: every scope listed must be
151
+ held. Because they are checked first and fail closed, a scope can only ever
152
+ *narrow* access — it never grants what `permissions` would deny.
149
153
 
150
- 1. **Global permissions** (`addGlobalPermission`) — AND'd together. A broad baseline that can only narrow access.
151
- 2. **The function's own `permissions`** — OR'd groups (OR-of-ANDs), as above.
154
+ Declare the scope tree once with `defineScope`. The body is a no-op that
155
+ tree-shakes away; the CLI reads the call by AST and generates a `ScopeId` union,
156
+ so a function naming an undeclared scope fails the build rather than silently
157
+ gating on nothing.
152
158
 
153
- The gates are independent: a broad global (e.g. `signedInUser`) can **never** satisfy an admin-only function's own requirement. Each function still enforces its own `permissions` in full.
159
+ ```typescript
160
+ // src/scopes.ts
161
+ import { defineScope } from '#pikku'
162
+
163
+ defineScope({
164
+ admin: {
165
+ displayName: 'Administration',
166
+ description: 'Administrative access',
167
+ scopes: {
168
+ invoices: {
169
+ description: 'Invoice management',
170
+ scopes: {
171
+ create: { description: 'Create invoices' },
172
+ void: { description: 'Void invoices' },
173
+ },
174
+ },
175
+ },
176
+ },
177
+ billing: {},
178
+ })
179
+ ```
180
+
181
+ Every node is grantable, keyed by segment: the above yields `admin`,
182
+ `admin:invoices`, `admin:invoices:create`, `admin:invoices:void` and `billing`.
183
+ Scopes may be declared across more than one file — the declarations merge.
184
+
185
+ ```typescript
186
+ export const voidInvoice = pikkuFunc({
187
+ scopes: ['admin:invoices:void'],
188
+ permissions: { owner: isInvoiceOwner },
189
+ func: async ({ db }, { invoiceId }) => { ... },
190
+ })
191
+ ```
192
+
193
+ A grant satisfies a required scope if it is the scope itself, an ancestor of it,
194
+ or a wildcard at any level — so a session holding `admin` satisfies
195
+ `admin:invoices:void`, and `admin:*` does too. A missing scope throws
196
+ `MissingScopeError` naming the first one that failed.
197
+
198
+ `scopes` requires a session and so is unavailable on `pikkuSessionlessFunc`:
199
+ scopes fail closed, an anonymous caller holds none, and a sessionless function
200
+ with scopes would reject every caller it exists to serve. Gate those with
201
+ `permissions`, which receive the optional session and may pass anonymous.
202
+
203
+ ## The Three Gates
204
+
205
+ Authorization is three independent gates, evaluated in this order, all of which must pass:
206
+
207
+ 1. **Scopes** (`scopes`) — AND'd, checked before input validation. Fails closed.
208
+ 2. **Global permissions** (`addGlobalPermission`) — AND'd together. A broad baseline that can only narrow access.
209
+ 3. **The function's own `permissions`** — OR'd groups (OR-of-ANDs), as above.
210
+
211
+ The gates are independent: a broad global (e.g. `isEmployee`) can **never** satisfy an admin-only function's own requirement. Each function still enforces its own `scopes` and `permissions` in full.
212
+
213
+ ## The Sanctioned Exception: `permissionsInBody`
214
+
215
+ A few checks genuinely cannot be expressed as a permission — verifying a webhook
216
+ signature, a signed token, or an invite code, where the "identity" arrives in the
217
+ payload and there is no session to check. For those, declare
218
+ `permissionsInBody: true` on the function and keep the check in the body.
219
+
220
+ ```typescript
221
+ export const handleStripeWebhook = pikkuSessionlessFunc({
222
+ permissionsInBody: true,
223
+ auth: false,
224
+ func: async ({ stripe }, data, { http }) => {
225
+ stripe.webhooks.constructEvent(data.raw, http.request.header('stripe-signature'), secret)
226
+ // ...
227
+ },
228
+ })
229
+ ```
230
+
231
+ This is a last resort, and it is purely declarative — it grants nothing and
232
+ enforces nothing. Its only job is to tell the auditor that this function's
233
+ apparent openness is deliberate, so asserting it falsely disables the very check
234
+ that would have caught the mistake. It requires `"allow": { "permissionsInBody": true }`
235
+ in `pikku.config.json`, which keeps the decision visible at the project level.
236
+ Prefer `permissions` whenever the check can be expressed as one — they are
237
+ declared, inspectable, and reusable.
154
238
 
155
239
  ## Complete Example
156
240
 
@@ -158,10 +242,6 @@ The gates are independent: a broad global (e.g. `signedInUser`) can **never** sa
158
242
  // src/permissions.ts
159
243
  import { pikkuAuth, pikkuPermission } from '#pikku'
160
244
 
161
- export const isAuthenticated = pikkuAuth(
162
- async (_services, session) => !!session
163
- )
164
-
165
245
  export const isVerified = pikkuAuth(
166
246
  async (_services, session) => !!session?.emailVerified
167
247
  )
@@ -179,7 +259,7 @@ export const deleteOrg = pikkuFunc({
179
259
  },
180
260
  permissions: {
181
261
  verified: isVerified,
182
- owner: [isAuthenticated, isOrgMember],
262
+ owner: [isVerified, isOrgMember],
183
263
  },
184
264
  })
185
265
  ```
@@ -46,10 +46,18 @@ No constructor parameters. Creates a Pino logger instance.
46
46
  **Methods:**
47
47
 
48
48
  - `setLevel(level: LogLevel): void` — Set minimum log level.
49
- - `info(messageOrObj: string | Record<string, any> | Error): void`
50
- - `warn(messageOrObj: string | Record<string, any> | Error): void`
51
- - `error(messageOrObj: string | Record<string, any> | Error): void`
52
- - `debug(messageOrObj: string | Record<string, any>): void`
49
+ - `info(messageOrObj: string | Record<string, any> | Error, ...meta): void`
50
+ - `warn(messageOrObj: string | Record<string, any> | Error, ...meta): void`
51
+ - `error(messageOrObj: string | Record<string, any> | Error, ...meta): void`
52
+ - `debug(message: string, ...meta): void` — string only; the object form is not accepted here
53
+
54
+ Every argument, first and trailing, is `Safe<>`-guarded. A `SecretValue` nested
55
+ anywhere in what you log collapses the call to `never` and it stops compiling.
56
+ An unrevealed secret would print as `[secret]` regardless — the guard is what
57
+ makes logging one a deliberate act rather than an accident.
58
+
59
+ `setLevel` maps Pikku's `LogLevel` enum onto Pino's own level strings, so pass
60
+ the enum (or its name) rather than a raw Pino level.
53
61
 
54
62
  ## Usage Patterns
55
63
 
@@ -120,12 +120,12 @@ Include this section **only if you are actually recommending a rebuild** onto it
120
120
 
121
121
  **TanStack Start (the web framework) — instead of the incumbent (Next.js).**
122
122
  - *Buys you:* modern, tidy developer experience; strong type-safety that catches whole classes of bugs before users see them; fast iteration; fine-grained control; deploys well to modern/edge hosting. The libraries underneath it — the TanStack ecosystem (Query, Router, Table) — are mature, battle-tested and everywhere in React.
123
- - *Costs you (the honest tradeoff — maturity of the framework itself):* separate the ecosystem from the framework. Query/Router/Table are mature; **TanStack Start, the framework that wraps them, has not shipped a stable 1.0** — its own maintainers describe it as a release candidate that is feature-complete with a stable API, and tell production users to lock to an exact version and follow the last-mile changes into 1.0. In practice that means pinning your version and budgeting for occasional upgrade work as it settles, rather than upgrading casually. On top of that it's newer than the incumbent (Next.js), which has the largest ecosystem — fewer ready-made templates and third-party examples, and a smaller (though growing) pool of developers who've used *this specific* framework, which can make hiring slightly slower.
124
- - *Usually:* a credible, modern choice on a mature foundation, but a **pre-1.0 one** — so it carries pinning and upgrade risk that the incumbent does not. Reasonable if the team wants the type-safety and is willing to track the framework to 1.0; harder to justify if nobody has capacity to own upgrades.
123
+ - *Costs you (the honest tradeoff — maturity of the framework itself):* separate the ecosystem from the framework. Query/Router/Table are mature; the framework that wraps them is younger, and **you must check its release stage on tanstack.com/start at the moment you write** — do not infer it from the npm version. `@tanstack/react-start` has been on 1.x since early 2025 because its major tracks the **Router** version line, so "1.168.x" says nothing about whether Start itself has shipped a stable 1.0. If it is still pre-1.0, the cost is pinning an exact version and budgeting for upgrade work as it settles. Either way it is newer than the incumbent (Next.js), which has the largest ecosystem — fewer ready-made templates and third-party examples, and a smaller (though growing) pool of developers who've used *this specific* framework, which can make hiring slightly slower.
124
+ - *Usually:* a credible, modern choice on a mature foundation. Whether it also carries pinning-and-upgrade risk depends on the release stage you just checked — say which you found, rather than repeating either verdict from here.
125
125
 
126
126
  **Pikku (the framework a rebuild would land on) — instead of staying where you are.**
127
127
  - *Buys you:* one way to write a capability and drive it from anywhere — web, background jobs, timers, realtime, AI assistants, the command line — so a feature is written once instead of five times. Type-safe clients and the API spec fall out of the code rather than being hand-maintained until they drift. The sprawl an organically-grown app accumulates collapses into one shape a small team can hold in its head.
128
- - *Costs you (the honest tradeoff — it is younger than anything it would replace):* Pikku has **not shipped a stable 1.0** — it's 0.12.x, and 0.13 is the first release that will promise backwards compatibility. Until then upgrades can break you. In practice: pin your version, budget for upgrade work, and know that the community, the ready-made examples, and the pool of developers who have used it are all far smaller than the incumbent's — smaller than TanStack Start's, let alone Next.js's. Being pre-1.0 is normal for a young framework, and survivable, but it is a real cost and it is the reader's to weigh, not yours to skip.
128
+ - *Costs you (the honest tradeoff — it is younger than anything it would replace):* Pikku has **not shipped a stable 1.0** — at the time of writing it is 0.12.x, and 0.13 is the first release that promises backwards compatibility; check the published version rather than repeating this one. Until then upgrades can break you. In practice: pin your version, budget for upgrade work, and know that the community, the ready-made examples, and the pool of developers who have used it are all far smaller than the incumbent's — smaller than TanStack Start's, let alone Next.js's. Being pre-1.0 is normal for a young framework, and survivable, but it is a real cost and it is the reader's to weigh, not yours to skip.
129
129
  - *Usually:* worth it when the real problem is sprawl — many surfaces, hand-maintained glue, the same rule implemented three slightly different ways — and the team wants one shape instead of five. Harder to justify for an app that works and needs a few rewires: those are usually cheaper in place. If nobody has capacity to own upgrades, that's a real reason to wait.
130
130
 
131
131
  Check these statuses before you write them up rather than repeating them from here — a framework's release stage moves, and the point is the current fact, not this example.
@@ -43,22 +43,48 @@ wireQueueWorker({
43
43
  name: string, // Queue name (unique identifier)
44
44
  func: PikkuFunc, // Worker function
45
45
  config?: {
46
- batchSize?: number, // Process N jobs at once
47
- removeOnComplete?: number | boolean, // Clean up completed jobs
46
+ batchSize?: number, // Total worker concurrency
47
+ prefetch?: number,
48
+ pollInterval?: number, // ms
49
+ visibilityTimeout?: number, // seconds
50
+ lockDuration?: number, // ms
51
+ drainDelay?: number, // seconds
52
+ removeOnComplete?: number, // how many completed jobs to RETAIN (a count, not an age)
53
+ removeOnFail?: number, // how many failed jobs to RETAIN
54
+ maxStalledCount?: number,
55
+ autorun?: boolean,
56
+ groupConcurrency?: number | GroupConcurrencyConfig, // must not exceed batchSize
48
57
  },
49
58
  })
50
59
  ```
51
60
 
61
+ Not every adapter supports every option. Each adapter declares a
62
+ `QueueConfigMapping`, and unsupported keys are dropped with a warning rather than
63
+ silently ignored — so check the startup logs if a setting appears to have no
64
+ effect.
65
+
66
+ `groupConcurrency` limits how many jobs run concurrently *per group* (jobs
67
+ carrying a `JobGroup` with an `id` and optional `tier`), so one noisy tenant
68
+ cannot consume the whole worker:
69
+
70
+ ```typescript
71
+ groupConcurrency: { default: 2, tiers: { enterprise: 10 } }
72
+ ```
73
+
52
74
  ### Wire Object (`wire.queue`)
53
75
 
54
76
  Inside queue worker functions:
55
77
 
56
78
  ```typescript
57
- wire.queue.updateProgress(percent: number) // Report progress (0-100)
58
- wire.queue.discard(reason: string) // Silently discard job
59
- wire.queue.fail(reason: string) // Mark job as failed
79
+ wire.queue.updateProgress(progress: number | string | object) // Report progress
80
+ wire.queue.discard(reason?: string) // Silently discard job (throws QueueJobDiscardedError)
81
+ wire.queue.fail(reason?: string) // Mark job as failed
60
82
  ```
61
83
 
84
+ `updateProgress` is not limited to a 0-100 percentage — a string or an object
85
+ lets a long job report a stage ("rendering page 4/20") that a dashboard can show
86
+ directly.
87
+
62
88
  ### Job Publishing
63
89
 
64
90
  ```typescript
@@ -69,13 +95,15 @@ Options:
69
95
 
70
96
  ```typescript
71
97
  {
72
- priority?: number, // Higher = processed first
73
- delay?: number, // Delay in ms before processing
74
- attempts?: number, // Max retry attempts
75
- backoff?: {
76
- type: 'exponential' | 'fixed',
77
- delay: number, // Base delay in ms
78
- },
98
+ retryAttempts?: number, // Max retry attempts
99
+ retryDelay?: number, // Base delay in ms
100
+ retryBackoff?: 'linear' | 'exponential' | 'fixed',
101
+ deadLetterQueue?: string, // Where exhausted jobs land
102
+ messageRetention?: number,// Seconds
103
+ priority?: number, // Higher numbers run first
104
+ fifo?: boolean,
105
+ timeout?: number, // ms
106
+ delay?: number, // ms before the job becomes eligible
79
107
  }
80
108
  ```
81
109
 
@@ -146,8 +174,9 @@ const jobId = await queue.add(
146
174
  {
147
175
  priority: 10,
148
176
  delay: 5000,
149
- attempts: 3,
150
- backoff: { type: 'exponential', delay: 1000 },
177
+ retryAttempts: 3,
178
+ retryBackoff: 'exponential',
179
+ retryDelay: 1000,
151
180
  }
152
181
  )
153
182
  ```
@@ -157,7 +186,7 @@ const jobId = await queue.add(
157
186
  After `npx pikku all`:
158
187
 
159
188
  ```typescript
160
- import { PikkuQueue } from '.pikku/pikku-queue.gen.js'
189
+ import { PikkuQueue } from '#pikku/pikku-queue.gen.js'
161
190
 
162
191
  const queue = new PikkuQueue(queueService)
163
192
 
@@ -167,7 +196,7 @@ const jobId = await queue.add('todo-reminders', {
167
196
  })
168
197
 
169
198
  const job = await queue.getJob('todo-reminders', jobId)
170
- const status = await job.status() // 'waiting' | 'active' | 'completed' | 'failed'
199
+ const status = await job.status() // 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'
171
200
  const result = await job.waitForCompletion(30_000)
172
201
  ```
173
202
 
@@ -30,11 +30,16 @@ import {
30
30
  usePikkuFetch,
31
31
  usePikkuRPC,
32
32
  usePikkuRealtime,
33
+ usePikkuAgent,
34
+ usePikkuWorkflow,
35
+ asI18n,
33
36
  } from '@pikku/react'
34
37
  ```
35
38
 
36
- Five exports. `usePikkuRealtime` is only valid when you wired a
37
- `PikkuRealtime` class via `createPikku` — see step 3 below.
39
+ `usePikkuRealtime` is only valid when you wired a `PikkuRealtime` class via
40
+ `createPikku` — see the setup section. `usePikkuAgent` and `usePikkuWorkflow`
41
+ are thin bindings over the RPC client that pin one agent/workflow name, so a
42
+ component never repeats it. `asI18n` is the i18n brand (see **pikku-i18n**).
38
43
 
39
44
  ## Resolving the server URL
40
45
 
@@ -175,31 +180,53 @@ helpers live in **pikku-realtime**.
175
180
  | Paginate | **usePikkuInfiniteQuery** (react-query) |
176
181
  | One-off call from an event handler | `usePikkuRPC()` direct |
177
182
  | Hit a REST endpoint (not RPC) | `usePikkuFetch()` |
178
- | Run a workflow | **pikku-workflows-client** |
183
+ | Run one named workflow | `usePikkuWorkflow('name')` → `.start/.run/.status` |
184
+ | Talk to one named AI agent | `usePikkuAgent('name')` → `.run/.stream/.approve` |
185
+ | Longer-running workflow UX | **pikku-workflows-client** |
179
186
  | Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |
180
187
 
181
188
  The first three live in your generated `api.gen.ts` (see the
182
- **pikku-react-query** skill). This skill covers the bottom four rows.
189
+ **pikku-react-query** skill). This skill covers the rest.
190
+
191
+ `usePikkuAgent` and `usePikkuWorkflow` bind the name once and hand back the
192
+ call methods with it already applied:
193
+
194
+ ```tsx
195
+ const agent = usePikkuAgent('todo-agent')
196
+ const { text } = await agent.run({ message, threadId })
197
+
198
+ const workflow = usePikkuWorkflow('onboardUser')
199
+ const { runId } = await workflow.start({ email })
200
+ const state = await workflow.status(runId)
201
+ ```
183
202
 
184
203
  ## Authentication
185
204
 
186
- Auth is handled at the `PikkuFetch` layer — pass options to `createPikku`
187
- or set headers on the fetch instance after creation. Common pattern:
205
+ Auth is handled at the `PikkuFetch` layer, and `createPikku`'s options object
206
+ *is* `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a
207
+ `fetchOptions` key:
188
208
 
189
209
  ```tsx
190
210
  const pikku = createPikku(PikkuFetch, PikkuRPC, {
191
211
  serverUrl: apiUrl(),
192
- fetchOptions: {
193
- onRequest: (req) => {
194
- const token = localStorage.getItem('token')
195
- if (token) req.headers.set('Authorization', `Bearer ${token}`)
196
- },
197
- },
212
+ credentials: 'include', // cookie sessions
213
+ authHeaders: { jwt: token }, // or { apiKey }
214
+ transformDate: true,
198
215
  })
199
216
  ```
200
217
 
201
- Exact option names depend on the `@pikku/fetch` version — read
202
- `PikkuFetch`'s constructor type if unsure.
218
+ There is no request-interceptor hook. For a token that changes after startup,
219
+ call the setter on the shared instance — RPC and realtime pick it up because
220
+ they hold the same fetch:
221
+
222
+ ```tsx
223
+ pikku.fetch.setAuthorizationJWT(token) // null clears it
224
+ pikku.fetch.setAPIKey(key)
225
+ pikku.fetch.setHeader('x-tenant', tenantId)
226
+ ```
227
+
228
+ `authHeaders.jwt` becomes `Authorization: Bearer …` and `authHeaders.apiKey`
229
+ becomes `X-API-KEY`; setting a JWT takes precedence over an API key.
203
230
 
204
231
  ## What NOT to do
205
232
 
@@ -17,7 +17,7 @@ Use this skill as an execution checklist, not reference material.
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
19
  Pikku generates a typed React Query layer from your backend `expose: true`
20
- functions. You don''t write `useQuery`/`useMutation` against `fetch`
20
+ functions. You don't write `useQuery`/`useMutation` against `fetch`
21
21
  yourself — you call hooks named after RPCs and get full type inference for
22
22
  input + output.
23
23
 
@@ -184,25 +184,29 @@ refetch.
184
184
 
185
185
  ### `usePikkuInfiniteQuery(name, data, options?)`
186
186
 
187
- Only available for RPCs whose output has a `nextCursor?: string | null`
188
- field — typically a list endpoint with pagination. The hook auto-feeds
189
- `nextCursor` into the next page's request.
187
+ The hook's `name` parameter is narrowed to RPCs whose **output** has a
188
+ `nextCursor?: string | null` field, so calling it with anything else is a type
189
+ error rather than a missing hook. That output field is read after each page and
190
+ sent back as the **input** field `cursor` — which is why the `data` you pass is
191
+ `Omit<input, 'cursor'>`: the hook owns that key.
190
192
 
191
193
  ```tsx
192
194
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
193
195
  usePikkuInfiniteQuery('listTodos', { limit: 20 })
194
196
 
195
- const todos = data?.pages.flatMap((p) => p.rows) ?? []
197
+ const todos = data?.pages.flatMap((p) => p.todos) ?? []
196
198
  ```
197
199
 
198
- If the hook isn't generated for an RPC, the RPC's output doesn't include
199
- `nextCursor` — paginate it on the backend or use `usePikkuQuery` with
200
- manual cursor state.
200
+ So the backend contract is a pair: output `nextCursor`, input `cursor`. An RPC
201
+ missing either one paginates with `usePikkuQuery` and manual cursor state
202
+ instead.
201
203
 
202
204
  ## Workflow hooks
203
205
 
204
- When the project has workflows (`capabilities.workflow: true`), three
205
- extra hooks are generated. See the **pikku-workflows-client** skill.
206
+ When the project defines any workflow, the same file also gains
207
+ `useStartWorkflow(name)` (mutation → `{ runId }`), `useRunWorkflow(name)`
208
+ (mutation → the workflow's output) and `useWorkflowStatus(name, runId?)` (query,
209
+ disabled until `runId` is set). See the **pikku-workflows-client** skill.
206
210
 
207
211
  ## Calling RPCs without React Query
208
212
 
@@ -46,7 +46,9 @@ import type { EventHubService } from '@pikku/core/channel'
46
46
  import type { EventHubTopics } from './eventhub-topics.js'
47
47
 
48
48
  export interface SingletonServices extends CoreSingletonServices<Config> {
49
- eventHub?: EventHubService<EventHubTopics>
49
+ // `CoreSingletonServices` declares eventHub optional; re-declare it required
50
+ // so functions can use it without a `if (eventHub)` guard on every publish.
51
+ eventHub: EventHubService<EventHubTopics>
50
52
  }
51
53
 
52
54
  // services.ts
@@ -57,6 +59,10 @@ const eventHub = new LocalEventHubService<EventHubTopics>()
57
59
  For multi-instance deployments use `CloudflareEventHubService` /
58
60
  `LambdaEventHubService` / `UWSEventHubService` instead — same interface.
59
61
 
62
+ If a deployment genuinely has no eventHub, that belongs in `services.ts` (don't
63
+ create the service there), not as an optional type every function has to guard —
64
+ see `pikku-services`.
65
+
60
66
  ## 2. Enable the server side
61
67
 
62
68
  ```bash
@@ -85,20 +91,28 @@ Add to `pikku.config.json`:
85
91
  }
86
92
  ```
87
93
 
88
- Run `pikku all` (or `pikku realtime` to regenerate just this file). The generated
89
- file exports two surfaces:
94
+ Run `pikku all` (or `pikku realtime` to regenerate just this file). Everything is
95
+ on one class — both transports are methods, so switching from WebSocket to SSE is
96
+ a one-word change, not a different import:
90
97
 
91
98
  ```ts
92
99
  export class PikkuRealtime {
93
- constructor(options: { url: string; reconnect?: boolean; ... })
100
+ constructor(options?: { reconnect?: boolean; reconnectDelayMs?: number; reconnectMaxDelayMs?: number })
101
+ setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
102
+
103
+ // WebSocket at /events — many topics on one connection
94
104
  subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void
95
- unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: ...): void
105
+ unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: (data: EventHubTopics[K]) => void): void
106
+
107
+ // SSE at GET /events/:topic — one EventSource per topic
108
+ subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }
109
+
110
+ // 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
113
+
96
114
  close(): void
97
115
  }
98
-
99
- export function subscribeToTopicViaSSE<K extends keyof EventHubTopics>(
100
- baseUrl: string, topic: K, handler: (data: EventHubTopics[K]) => void
101
- ): { close: () => void }
102
116
  ```
103
117
 
104
118
  Without `realtimeEventHubTopicsImport`, the client falls back to
@@ -108,9 +122,19 @@ subscribe/unsubscribe.
108
122
  ## 4. Publish events from a function
109
123
 
110
124
  The `/events` channel listens for client subscriptions; the eventHub fans out
111
- publishes. Envelope the payload with `topic` so the client dispatcher works; the
112
- `null` channelId means "broadcast to all subscribers" (pass a specific channel id
113
- to exclude/include a single connection):
125
+ publishes:
126
+
127
+ ```ts
128
+ publish(topic, channelId: string | null, data, isBinary?)
129
+ ```
130
+
131
+ The middle argument is the channel to **skip**, not the one to send to — pass
132
+ `null` to reach every subscriber, or the current `channel.channelId` when the
133
+ originating connection has already applied the change locally and would otherwise
134
+ render it twice.
135
+
136
+ Envelope the payload as `{ topic, data }`: the generated client dispatches on the
137
+ `topic` field, so a bare payload arrives but no handler fires.
114
138
 
115
139
  ```ts
116
140
  import { pikkuFunc } from '#pikku'
@@ -123,12 +147,10 @@ export const createTodo = pikkuFunc({
123
147
  .insertInto('todos').values(data).returningAll()
124
148
  .executeTakeFirstOrThrow()
125
149
 
126
- if (eventHub) {
127
- await eventHub.publish('todo-created', null, {
128
- topic: 'todo-created',
129
- data: { todo },
130
- })
131
- }
150
+ await eventHub.publish('todo-created', null, {
151
+ topic: 'todo-created',
152
+ data: { todo },
153
+ })
132
154
  return { id: todo.id }
133
155
  },
134
156
  })
@@ -215,10 +237,10 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
215
237
 
216
238
  | Need | Use |
217
239
  | ------------------------------------------ | ----------------------------- |
218
- | Many topics in one connection | **PikkuRealtime** (WebSocket) |
219
- | Single live stream, simple cleanup | **subscribeToTopicViaSSE** |
220
- | Bidirectional (client also sends messages) | **PikkuRealtime** |
221
- | WebSockets blocked by infra | **subscribeToTopicViaSSE** |
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` |
222
244
 
223
245
  Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
224
246
  all topics for the dead channel id). Don't write manual cleanup unless you're
@@ -43,15 +43,25 @@ All services accept a Redis connection (ioredis `Redis` instance, `RedisOptions`
43
43
  | `RedisDeploymentService` | `DeploymentService` | Deployment state management |
44
44
  | `RedisAgentRunService` | `AgentRunService` | Agent execution tracking |
45
45
  | `RedisSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |
46
+ | `RedisSessionStore` | `SessionStore` | Persisted user sessions |
46
47
 
47
48
  ### Secret Service
48
49
 
50
+ Envelope encryption: `key` derives the KEK that wraps each secret's own DEK.
51
+ Keeping `previousKey` set is what makes `rotateKEK()` possible — it re-wraps
52
+ every secret onto the current key and returns the new version.
53
+
49
54
  ```typescript
50
55
  import { RedisSecretService } from '@pikku/redis'
51
56
 
52
57
  const secrets = new RedisSecretService(
53
58
  connectionOrConfig: Redis | RedisOptions | string,
54
- config: { kekSecret: string; salt: string }
59
+ config: {
60
+ key: string // the KEK passphrase
61
+ keyVersion?: number // defaults to 1
62
+ previousKey?: string // required to rotate
63
+ keyPrefix?: string // namespaces the redis keys
64
+ }
55
65
  )
56
66
 
57
67
  await secrets.getSecret<T = string>(key: string): Promise<T>
@@ -81,8 +91,7 @@ const createSingletonServices = pikkuServices(async (config) => {
81
91
  const workflowService = new RedisWorkflowService(config.redisUrl)
82
92
 
83
93
  const secrets = new RedisSecretService(config.redisUrl, {
84
- kekSecret: config.kekSecret,
85
- salt: config.salt,
94
+ key: config.kekPassphrase,
86
95
  })
87
96
 
88
97
  return { config, logger, channelStore, workflowService, secrets }