@pikku/cli 0.12.151 → 0.12.153

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 (275) hide show
  1. package/console-app/assets/{HelpPanel-CxjgE40i.js → HelpPanel-0XQ4CyKC.js} +1 -1
  2. package/console-app/assets/{abnfDiagram-VCTEODGH-UPW1PlxM.js → abnfDiagram-VCTEODGH-DZTF_2aX.js} +1 -1
  3. package/console-app/assets/architecture-7GRP2DOG-NotVOJ5Y.js +1 -0
  4. package/console-app/assets/{architectureDiagram-5GKGNRK7-B3XFrvZk.js → architectureDiagram-5GKGNRK7-Re4-6X-k.js} +1 -1
  5. package/console-app/assets/{blockDiagram-I7D4REHJ-Ca0v9UJQ.js → blockDiagram-I7D4REHJ-Dbj7SnRK.js} +1 -1
  6. package/console-app/assets/{c4Diagram-7LVT6UL2-CLewVsj_.js → c4Diagram-7LVT6UL2-DUr02Wxh.js} +1 -1
  7. package/console-app/assets/channel-DU63_zXh.js +1 -0
  8. package/console-app/assets/{chunk-4HAMMTFA-BwhbhOD9.js → chunk-4HAMMTFA-DjatzNf_.js} +1 -1
  9. package/console-app/assets/{chunk-75Z2AOVW-C7nAgNk0.js → chunk-75Z2AOVW-BGsmWEdU.js} +1 -1
  10. package/console-app/assets/{chunk-DU6HZSFF-D4yYAcHt.js → chunk-DU6HZSFF-CC7WiVgI.js} +1 -1
  11. package/console-app/assets/{chunk-F27PBJKO-UHllOPps.js → chunk-F27PBJKO-BATuW9Ej.js} +1 -1
  12. package/console-app/assets/{chunk-GMAD6QVW-C0l_vm7T.js → chunk-GMAD6QVW-CgHNo00B.js} +1 -1
  13. package/console-app/assets/{chunk-GVQU2GXP-h-H2Oird.js → chunk-GVQU2GXP-VT_xckd9.js} +1 -1
  14. package/console-app/assets/{chunk-IMKFNOWR-DJ_W9CSg.js → chunk-IMKFNOWR-BhSVbWtF.js} +1 -1
  15. package/console-app/assets/{chunk-L3NEJ4N5-Ce9oniwE.js → chunk-L3NEJ4N5-HUF6a3Xl.js} +1 -1
  16. package/console-app/assets/{chunk-OSK3NFVY-CdGE5qxS.js → chunk-OSK3NFVY-C_LPDGIB.js} +1 -1
  17. package/console-app/assets/{chunk-P2QGCYS3-DK1pHDdz.js → chunk-P2QGCYS3-lWDBgw4Q.js} +1 -1
  18. package/console-app/assets/{chunk-POPQ4Y6H-DjevEFNo.js → chunk-POPQ4Y6H--6JTzmZJ.js} +1 -1
  19. package/console-app/assets/{chunk-PWAF6VOD-BVF5N1No.js → chunk-PWAF6VOD-C1GqaJJs.js} +1 -1
  20. package/console-app/assets/{chunk-SHT3W25Y-BX8We-wN.js → chunk-SHT3W25Y-Dd_4Zpdj.js} +1 -1
  21. package/console-app/assets/{chunk-SVP7TREG-Cl0GgAPC.js → chunk-SVP7TREG-B8OY7dfD.js} +1 -1
  22. package/console-app/assets/{chunk-TICWLB2K-IWZAWDhu.js → chunk-TICWLB2K-B8S7Hmnu.js} +1 -1
  23. package/console-app/assets/chunk-XXDRQBXY-BLuHuEZN.js +1 -0
  24. package/console-app/assets/classDiagram-ZZMXUADV-BTYfUi7u.js +1 -0
  25. package/console-app/assets/classDiagram-v2-VYDZK3BY-BTYfUi7u.js +1 -0
  26. package/console-app/assets/{cose-bilkent-JH36ORCC-GsQ0JAnL.js → cose-bilkent-JH36ORCC-C6RM8oPO.js} +1 -1
  27. package/console-app/assets/{cynefin-OW5HDTMX-hZsAZp_5.js → cynefin-OW5HDTMX-COh9rFYY.js} +1 -1
  28. package/console-app/assets/{cynefinDiagram-5FMLGOSQ-CsyMovx1.js → cynefinDiagram-5FMLGOSQ-Dd9AR17w.js} +1 -1
  29. package/console-app/assets/{dagre-BP8XEFz-.js → dagre-BDMvDAer.js} +1 -1
  30. package/console-app/assets/{dagre-GXQ25YYZ-Jg5285rx.js → dagre-GXQ25YYZ-C_nBUPgr.js} +1 -1
  31. package/console-app/assets/{diagram-S7CK7UJ4-n9JiR8HK.js → diagram-S7CK7UJ4-BGOobQeC.js} +1 -1
  32. package/console-app/assets/{diagram-UQ7AKVKN-Cde-dgbI.js → diagram-UQ7AKVKN-CAF_vcj0.js} +1 -1
  33. package/console-app/assets/{diagram-VSXAHHWV-CPZUg4FP.js → diagram-VSXAHHWV-q-aY0Nsc.js} +1 -1
  34. package/console-app/assets/{diagram-VX7I27RA-B2rCDWvn.js → diagram-VX7I27RA-CnUibSOe.js} +1 -1
  35. package/console-app/assets/{diagram-Z3DM3KII-UwO0hte3.js → diagram-Z3DM3KII-DY0V5kOJ.js} +1 -1
  36. package/console-app/assets/{ebnfDiagram-PWID7BFC-BGJ_PBlS.js → ebnfDiagram-PWID7BFC-CXp4CmEF.js} +1 -1
  37. package/console-app/assets/{erDiagram-RLTQ6QDP-CKsTScr3.js → erDiagram-RLTQ6QDP-CKnfXmPl.js} +1 -1
  38. package/console-app/assets/eventmodeling-NTZA5JFV-CBfQhnG8.js +1 -0
  39. package/console-app/assets/flowDiagram-HODETNUW-BnY3G7p6.js +1 -0
  40. package/console-app/assets/{ganttDiagram-EL5Y4UJY-koeTkIEA.js → ganttDiagram-EL5Y4UJY-D6v_RJ7o.js} +1 -1
  41. package/console-app/assets/{gitGraph-4MIJSDKK-DyRPbcM4.js → gitGraph-4MIJSDKK-y3kWDddY.js} +1 -1
  42. package/console-app/assets/{gitGraphDiagram-WWUBYQGX-DSIejrYw.js → gitGraphDiagram-WWUBYQGX-CBaE24KN.js} +1 -1
  43. package/console-app/assets/{graphlib-BpQ_697T.js → graphlib-DAA_TPmU.js} +1 -1
  44. package/console-app/assets/{index-Du9_fRSw.js → index-HuTd6kcC.js} +147 -147
  45. package/console-app/assets/{info-A6RAGUB7-C63f9an_.js → info-A6RAGUB7-BvxfRJ9-.js} +1 -1
  46. package/console-app/assets/{infoDiagram-27XIBGKW-Dz5gMcUF.js → infoDiagram-27XIBGKW-BlFmgW96.js} +1 -1
  47. package/console-app/assets/{ishikawaDiagram-5VMMS53U-m-nTAZTH.js → ishikawaDiagram-5VMMS53U-Dpwhnwwq.js} +1 -1
  48. package/console-app/assets/{journeyDiagram-3NMN7TZE-C7mjFY7i.js → journeyDiagram-3NMN7TZE-iOSeOQma.js} +1 -1
  49. package/console-app/assets/{kanban-definition-UXKFOSKX-Cm7PqOOP.js → kanban-definition-UXKFOSKX-DFGTB8S5.js} +1 -1
  50. package/console-app/assets/{line-RZNRGurC.js → line-B_5qZKoM.js} +1 -1
  51. package/console-app/assets/{linear-C2IGWMlF.js → linear-BmXUYu4J.js} +1 -1
  52. package/console-app/assets/{mermaid-parser.core-DN1yDqgB.js → mermaid-parser.core-DyPi3Lbr.js} +3 -3
  53. package/console-app/assets/{mermaid.core-DDRs3TbC.js → mermaid.core-S4y9jX8K.js} +4 -4
  54. package/console-app/assets/{mindmap-definition-YA3MSWOX-o6riOwlF.js → mindmap-definition-YA3MSWOX-Bl09mCaS.js} +1 -1
  55. package/console-app/assets/{packet-AYTQ26CC-B89F6gXG.js → packet-AYTQ26CC-B5zoasmU.js} +1 -1
  56. package/console-app/assets/{pegDiagram-XKGWAZYB-BQ1AAIaV.js → pegDiagram-XKGWAZYB-CAa0SOHf.js} +1 -1
  57. package/console-app/assets/{pie-WAS4IAKB-CzuwQlQz.js → pie-WAS4IAKB-C91xXCXY.js} +1 -1
  58. package/console-app/assets/{pieDiagram-E7YTZNPT-DlcGHWGT.js → pieDiagram-E7YTZNPT-CmUHVYwz.js} +1 -1
  59. package/console-app/assets/{quadrantDiagram-AXDQQJYC-DdITwqw-.js → quadrantDiagram-AXDQQJYC-XWsej4ss.js} +1 -1
  60. package/console-app/assets/{radar-RG4KPBEZ-DlJ29jAN.js → radar-RG4KPBEZ-B7ieCnSw.js} +1 -1
  61. package/console-app/assets/{railroad-74A4TZTK-BL49fdM8.js → railroad-74A4TZTK-KTKjMg4U.js} +1 -1
  62. package/console-app/assets/railroad-abnf-HS5TGJTU-e9AlsYmr.js +1 -0
  63. package/console-app/assets/railroad-ebnf-LZEXJU2U-BVX8UG1z.js +1 -0
  64. package/console-app/assets/railroad-peg-WCYAUIDC-BzWQFvD9.js +1 -0
  65. package/console-app/assets/{railroadDiagram-O6MQD6OU-BNgQGtF5.js → railroadDiagram-O6MQD6OU-oh1FjI2K.js} +1 -1
  66. package/console-app/assets/{requirementDiagram-BXWQKSXE-48lE_WeD.js → requirementDiagram-BXWQKSXE-D6z7hLfj.js} +1 -1
  67. package/console-app/assets/{sankeyDiagram-P5KCCOFB-WmTardCP.js → sankeyDiagram-P5KCCOFB-DFb92jfp.js} +1 -1
  68. package/console-app/assets/{sequenceDiagram-WJ2MYXX4-Bd5cHDMs.js → sequenceDiagram-WJ2MYXX4-BRKj-PG1.js} +1 -1
  69. package/console-app/assets/{src-BTXg_NtK.js → src-DYBVouJk.js} +1 -1
  70. package/console-app/assets/{stateDiagram-D77RDMKH-DBoCkqGv.js → stateDiagram-D77RDMKH-BfTLNw5t.js} +1 -1
  71. package/console-app/assets/stateDiagram-v2-MP3YSRHH-B8sMNnnF.js +1 -0
  72. package/console-app/assets/{swimlanes-42K2YHIH-lecUPBaK.js → swimlanes-42K2YHIH-DXkNYzX9.js} +1 -1
  73. package/console-app/assets/swimlanesDiagram-VR7AAH4N-CiZdLDyk.js +8 -0
  74. package/console-app/assets/{timeline-definition-24CTP7MA-CWq0OWm1.js → timeline-definition-24CTP7MA-B8m2V75P.js} +1 -1
  75. package/console-app/assets/{treeView-Q6P3EWNA-C7lEeqUz.js → treeView-Q6P3EWNA-BisqOzat.js} +1 -1
  76. package/console-app/assets/{treemap-WGGIJYW6-Bkx2DAoI.js → treemap-WGGIJYW6-DOxEXpb1.js} +1 -1
  77. package/console-app/assets/{vennDiagram-4TSXK5OY-Bw_kGRL3.js → vennDiagram-4TSXK5OY-ZeuU7o23.js} +1 -1
  78. package/console-app/assets/{wardley-WFR3VGLG-V0zki2L0.js → wardley-WFR3VGLG-sspeOMuz.js} +1 -1
  79. package/console-app/assets/{wardleyDiagram-VM6X3IG4-C5vrwEHM.js → wardleyDiagram-VM6X3IG4-CF2vzNJV.js} +1 -1
  80. package/console-app/assets/{xychartDiagram-S5SC5T6Z-B9uA0moC.js → xychartDiagram-S5SC5T6Z-BR9XBq3s.js} +1 -1
  81. package/console-app/index.html +1 -1
  82. package/dist/.pikku/addon/index.d.ts +1 -1
  83. package/dist/.pikku/addon/index.js +1 -1
  84. package/dist/.pikku/addon/pikku-addon-types.gen.d.ts +1 -1
  85. package/dist/.pikku/addon/pikku-addon-types.gen.js +1 -1
  86. package/dist/.pikku/agent/index.d.ts +1 -1
  87. package/dist/.pikku/agent/index.js +1 -1
  88. package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
  89. package/dist/.pikku/agent/pikku-model-aliases.gen.js +1 -1
  90. package/dist/.pikku/analytics/index.d.ts +1 -1
  91. package/dist/.pikku/analytics/index.js +1 -1
  92. package/dist/.pikku/analytics/pikku-analytics-types.gen.d.ts +1 -1
  93. package/dist/.pikku/analytics/pikku-analytics-types.gen.js +1 -1
  94. package/dist/.pikku/auth/index.d.ts +1 -1
  95. package/dist/.pikku/auth/index.js +1 -1
  96. package/dist/.pikku/auth/pikku-auth-types.gen.d.ts +1 -1
  97. package/dist/.pikku/auth/pikku-auth-types.gen.js +1 -1
  98. package/dist/.pikku/channel/index.d.ts +1 -1
  99. package/dist/.pikku/channel/index.js +1 -1
  100. package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
  101. package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
  102. package/dist/.pikku/cli/index.d.ts +1 -1
  103. package/dist/.pikku/cli/index.js +1 -1
  104. package/dist/.pikku/cli/pikku-cli-channel.js +21 -1
  105. package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
  106. package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
  107. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
  108. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
  109. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.json +38 -1
  110. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  111. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  112. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  113. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +94 -1
  114. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  115. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  116. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  117. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  118. package/dist/.pikku/error/index.d.ts +1 -1
  119. package/dist/.pikku/error/index.js +1 -1
  120. package/dist/.pikku/error/pikku-error-types.gen.d.ts +1 -1
  121. package/dist/.pikku/error/pikku-error-types.gen.js +1 -1
  122. package/dist/.pikku/function/index.d.ts +1 -1
  123. package/dist/.pikku/function/index.js +1 -1
  124. package/dist/.pikku/function/pikku-function-types.gen.d.ts +1 -1
  125. package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
  126. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  127. package/dist/.pikku/function/pikku-functions-meta.gen.json +78 -2
  128. package/dist/.pikku/function/pikku-functions.gen.js +1 -1
  129. package/dist/.pikku/gateway/index.d.ts +1 -1
  130. package/dist/.pikku/gateway/index.js +1 -1
  131. package/dist/.pikku/gateway/pikku-gateway-types.gen.d.ts +1 -1
  132. package/dist/.pikku/gateway/pikku-gateway-types.gen.js +1 -1
  133. package/dist/.pikku/http/index.d.ts +1 -1
  134. package/dist/.pikku/http/index.js +1 -1
  135. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  136. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  137. package/dist/.pikku/mcp/index.d.ts +1 -1
  138. package/dist/.pikku/mcp/index.js +1 -1
  139. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  140. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  141. package/dist/.pikku/middleware/index.d.ts +1 -1
  142. package/dist/.pikku/middleware/index.js +1 -1
  143. package/dist/.pikku/middleware/pikku-middleware-types.gen.d.ts +1 -1
  144. package/dist/.pikku/middleware/pikku-middleware-types.gen.js +1 -1
  145. package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +1 -1
  146. package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +1 -1
  147. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  148. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  149. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  150. package/dist/.pikku/queue/index.d.ts +1 -1
  151. package/dist/.pikku/queue/index.js +1 -1
  152. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  153. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  154. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  155. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  156. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  157. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  158. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +4 -0
  159. package/dist/.pikku/scenarios/index.d.ts +1 -1
  160. package/dist/.pikku/scenarios/index.js +1 -1
  161. package/dist/.pikku/scenarios/pikku-personas.gen.js +1 -1
  162. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +1 -1
  163. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +1 -1
  164. package/dist/.pikku/scenarios/pikku-scenario-types.gen.d.ts +1 -1
  165. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +1 -1
  166. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +1 -1
  167. package/dist/.pikku/scenarios/schemas/register.gen.d.ts +1 -1
  168. package/dist/.pikku/scenarios/schemas/register.gen.js +1 -1
  169. package/dist/.pikku/scheduler/index.d.ts +1 -1
  170. package/dist/.pikku/scheduler/index.js +1 -1
  171. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  172. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  173. package/dist/.pikku/schemas/register.gen.js +17 -1
  174. package/dist/.pikku/schemas/schemas/ExamplesAddInput.schema.json +1 -0
  175. package/dist/.pikku/schemas/schemas/ExamplesAddOutput.schema.json +1 -0
  176. package/dist/.pikku/schemas/schemas/ExamplesListInput.schema.json +1 -0
  177. package/dist/.pikku/schemas/schemas/ExamplesListOutput.schema.json +1 -0
  178. package/dist/.pikku/schemas/schemas/ExamplesShowInput.schema.json +1 -0
  179. package/dist/.pikku/schemas/schemas/ExamplesShowOutput.schema.json +1 -0
  180. package/dist/.pikku/schemas/schemas/FabricChangesFileInput.schema.json +1 -0
  181. package/dist/.pikku/schemas/schemas/FabricChangesFileOutput.schema.json +1 -0
  182. package/dist/.pikku/scopes/index.d.ts +1 -1
  183. package/dist/.pikku/scopes/index.js +1 -1
  184. package/dist/.pikku/scopes/pikku-flags.gen.d.ts +1 -1
  185. package/dist/.pikku/scopes/pikku-personas.gen.js +1 -1
  186. package/dist/.pikku/scopes/pikku-roles.gen.d.ts +1 -1
  187. package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
  188. package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
  189. package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
  190. package/dist/.pikku/secrets/index.d.ts +1 -1
  191. package/dist/.pikku/secrets/index.js +1 -1
  192. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
  193. package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
  194. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  195. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  196. package/dist/.pikku/services/pikku-meta-service.gen.d.ts +1 -1
  197. package/dist/.pikku/services/pikku-meta-service.gen.js +1 -1
  198. package/dist/.pikku/setup/index.d.ts +1 -1
  199. package/dist/.pikku/setup/index.js +1 -1
  200. package/dist/.pikku/setup/pikku-setup-types.gen.d.ts +1 -1
  201. package/dist/.pikku/setup/pikku-setup-types.gen.js +1 -1
  202. package/dist/.pikku/trigger/index.d.ts +1 -1
  203. package/dist/.pikku/trigger/index.js +1 -1
  204. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  205. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  206. package/dist/.pikku/variables/index.d.ts +1 -1
  207. package/dist/.pikku/variables/index.js +1 -1
  208. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
  209. package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
  210. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  211. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  212. package/dist/.pikku/workflow/index.d.ts +1 -1
  213. package/dist/.pikku/workflow/index.js +1 -1
  214. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +1 -1
  215. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +1 -1
  216. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  217. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  218. package/dist/bin/pikku-bin.mjs +2 -2
  219. package/dist/src/cli.wiring.js +51 -0
  220. package/dist/src/examples.gen.d.ts +17 -0
  221. package/dist/src/examples.gen.js +7 -0
  222. package/dist/src/fabric/fabric-commands.d.ts +37 -0
  223. package/dist/src/fabric/fabric-commands.js +32 -1
  224. package/dist/src/fabric/functions/changes-file.function.d.ts +64 -0
  225. package/dist/src/fabric/functions/changes-file.function.js +56 -0
  226. package/dist/src/fabric/functions/validate.function.d.ts +34 -0
  227. package/dist/src/fabric/functions/validate.function.js +118 -0
  228. package/dist/src/fabric/lib/config.d.ts +1 -0
  229. package/dist/src/fabric/lib/config.js +1 -1
  230. package/dist/src/functions/commands/dev-address.d.ts +27 -0
  231. package/dist/src/functions/commands/dev-address.js +45 -0
  232. package/dist/src/functions/commands/dev.js +6 -0
  233. package/dist/src/functions/commands/environment.d.ts +8 -0
  234. package/dist/src/functions/commands/environment.js +15 -0
  235. package/dist/src/functions/commands/examples.d.ts +170 -0
  236. package/dist/src/functions/commands/examples.js +28 -0
  237. package/dist/src/functions/commands/scenario.js +356 -273
  238. package/dist/src/functions/db/local-db.d.ts +12 -0
  239. package/dist/src/functions/db/local-db.js +1 -1
  240. package/dist/src/functions/db/postgres/scenario-baseline.d.ts +3 -0
  241. package/dist/src/functions/db/postgres/scenario-baseline.js +112 -0
  242. package/dist/src/functions/db/scenario-baseline.d.ts +51 -0
  243. package/dist/src/functions/db/scenario-baseline.js +60 -0
  244. package/dist/src/functions/db/sqlite/scenario-baseline.d.ts +3 -0
  245. package/dist/src/functions/db/sqlite/scenario-baseline.js +77 -0
  246. package/dist/src/functions/examples/command-schemas.d.ts +69 -0
  247. package/dist/src/functions/examples/command-schemas.js +15 -0
  248. package/dist/src/functions/examples/corpus.d.ts +115 -0
  249. package/dist/src/functions/examples/corpus.js +307 -0
  250. package/dist/src/functions/examples/project.d.ts +64 -0
  251. package/dist/src/functions/examples/project.js +363 -0
  252. package/dist/src/functions/examples/render.d.ts +4 -0
  253. package/dist/src/functions/examples/render.js +101 -0
  254. package/dist/src/functions/examples/run.d.ts +9 -0
  255. package/dist/src/functions/examples/run.js +242 -0
  256. package/dist/src/functions/examples/schemas.d.ts +65 -0
  257. package/dist/src/functions/examples/schemas.js +76 -0
  258. package/dist/src/functions/surface/render-surface-doc.js +32 -2
  259. package/dist/tsconfig.tsbuildinfo +1 -1
  260. package/package.json +5 -4
  261. package/snippets-meta.json +2 -0
  262. package/snippets.json +2 -0
  263. package/surface.json +1 -1
  264. package/console-app/assets/architecture-7GRP2DOG-BQN972Vc.js +0 -1
  265. package/console-app/assets/channel-B8xtOmL3.js +0 -1
  266. package/console-app/assets/chunk-XXDRQBXY-CGWcS5Ol.js +0 -1
  267. package/console-app/assets/classDiagram-ZZMXUADV-OGsmOlmO.js +0 -1
  268. package/console-app/assets/classDiagram-v2-VYDZK3BY-OGsmOlmO.js +0 -1
  269. package/console-app/assets/eventmodeling-NTZA5JFV-CnZOTLz3.js +0 -1
  270. package/console-app/assets/flowDiagram-HODETNUW-TYMCrte8.js +0 -1
  271. package/console-app/assets/railroad-abnf-HS5TGJTU-D9Ej_sin.js +0 -1
  272. package/console-app/assets/railroad-ebnf-LZEXJU2U-CjzlC3qv.js +0 -1
  273. package/console-app/assets/railroad-peg-WCYAUIDC-BPa6lIrj.js +0 -1
  274. package/console-app/assets/stateDiagram-v2-MP3YSRHH-BLbaZawL.js +0 -1
  275. package/console-app/assets/swimlanesDiagram-VR7AAH4N-CM_5HS_7.js +0 -8
@@ -0,0 +1,7 @@
1
+ // Generated by scripts/embed-examples.mjs — do not edit.
2
+ // Run `bun run embed` in @pikku/cli after changing anything under examples/.
3
+ //
4
+ // A plain typed module rather than a JSON import: the `bun --compile` binaries carry
5
+ // only the JS import graph, so a recipe read from disk ships to npm and not to the
6
+ // binary most people run. See scripts/embed-examples.mjs.
7
+ export const EXAMPLES = [{ "name": "ai-agent", "title": "AI agent (pikkuAgent + tool) — auto-served at /rpc/agent/<name>", "when": "The app needs an in-app AI assistant/chat. Pass the ASSISTANT's name as the entity (`--entity kitchen-assistant`) — it writes the agent and one example tool as separate files with the export name, the `name:` field and the filename all in agreement, which is the thing that otherwise 500s with \"AI agent not found\": the route is /rpc/agent/<exported const name>, and the frontend PikkuAgentChat `agentName` must equal that same identifier. Defining the agent is ENOUGH — pikku auto-exposes /rpc/agent/<name> (non-streaming) and /rpc/agent/<name>/stream (SSE). Do NOT wireHTTP it, do NOT hand-roll a /chat route. For a ONE-SHOT AI call that is NOT a chat use the dedicated scaffolds instead: ai-extract (text → typed fields), ai-vision (understand an image), ai-transcribe (audio → text), ai-image (text → image), ai-speech (text → audio). If a tool the agent calls has real-world consequences (sends/charges/deletes), gate it for human approval — see ai-approval-tool.", "lang": "ts", "entity": "assistant", "deferUntil": "entity-write", "requires": [], "steps": "THE TOOL. A TOOL is just a normal pikkuFunc the agent is allowed to call. Each tool\ngoes in its OWN *.function.ts (one func per file) and the agent in its own\n*.agent.ts; import the tool into the agent file WITH the .js extension\n(`import { suggestPriority } from '../functions/suggest-priority.function.js'`) and\npass the imported reference to `tools: [...]`. A tool needs `expose: true` (it is\nalso a real RPC) and, like every function, its I/O types come ONLY from the zod\nschemas.\n\n⚠️ EVERY name in `tools: [...]` MUST have a matching `import` at the TOP of the AGENT\nfile — this is the #1 agent-wiring loop. The natural move is to REUSE an existing\nCRUD function as a tool (e.g. \"the assistant can list animals\" → your\n`listAnimals`), but a bare identifier with no import fails codegen with\n`[PKU124] AI agent '<name>' tools array contains identifier 'listAnimals' that\ncould not be resolved to a pikkuFunc`. The fix is ALWAYS to add the import\n(`import { listAnimals } from '../functions/list-animals.function.js'`), never to\ndrop or rename the tool. If PKU124 lists 5 tools, you are missing 5 imports — add\nthem ALL in one edit, then `pikku all` ONCE (do not re-verify per import).\nA reused CRUD func also needs `expose: true` on its own declaration to be callable.\n\nThe tool does its work DIRECTLY in `func` via the injected services (kysely, …)\nscoped to session.userId — read/write the DB there. The agent decides WHEN to call\nit; you decide WHAT it does, so swap the shipped heuristic for whatever the domain\nneeds (a query, a write, an external call). Do NOT proxy to another function\nthrough `rpc` / `rpc.exposed()` — there is no such API in a tool; if a tool shares\nlogic with a CRUD function, extract a lib helper both call.\n\nTHE AGENT. Listing it is all you need — pikku generates the HTTP routes.\n⚠️ CRITICAL — THE AGENT'S ADDRESS IS ITS EXPORTED CONST NAME, NOT `name`. pikku\nregisters `addAIAgent('<exportedConstName>', ...)` and serves the routes off that\nSAME identifier:\n POST /rpc/agent/<exportedConstName> (non-streaming)\n POST /rpc/agent/<exportedConstName>/stream (SSE — what the chat UI uses)\nThe `name` field is human-facing metadata ONLY — it is NOT the URL segment and NOT\nwhat the frontend calls. To avoid the \"AI agent not found\" 500, keep the exported\nconst name and `name` IDENTICAL (one camelCase identifier for both), and the\nfrontend PikkuAgentChat `agentName` MUST equal that exported const name. As\nwritten the export is `assistant` and `name: 'assistant'` — if you rename it for\nyour domain (e.g. `todoAssistant`), set `name: 'todoAssistant'` too and pass\n`agentName=\"todoAssistant\"` in the chat page. NEVER a kebab-case `name` that\ndiffers from the export — that is exactly what breaks the assistant.\n\n`model` MUST be provider-prefixed — Fabric routes everything through one LiteLLM\nproxy, so use `openai/gpt-5.6-luna` (a bare alias fails). No API key to set up:\nFabric injects the AI credentials for the sandbox automatically.\n`goal` is the system prompt. `tools` is the list of pikkuFuncs it may call — add\nEVERY tool the assistant should be able to call, each with its import.", "source": "packages/cli/examples/ai/ai-agent.ts", "content": "//~ name: ai-agent\n//~ title: AI agent (pikkuAgent + tool) — auto-served at /rpc/agent/<name>\n//~ when: The app needs an in-app AI assistant/chat. Pass the ASSISTANT's name as the entity (`--entity kitchen-assistant`) — it writes the agent and one example tool as separate files with the export name, the `name:` field and the filename all in agreement, which is the thing that otherwise 500s with \"AI agent not found\": the route is /rpc/agent/<exported const name>, and the frontend PikkuAgentChat `agentName` must equal that same identifier. Defining the agent is ENOUGH — pikku auto-exposes /rpc/agent/<name> (non-streaming) and /rpc/agent/<name>/stream (SSE). Do NOT wireHTTP it, do NOT hand-roll a /chat route. For a ONE-SHOT AI call that is NOT a chat use the dedicated scaffolds instead: ai-extract (text → typed fields), ai-vision (understand an image), ai-transcribe (audio → text), ai-image (text → image), ai-speech (text → audio). If a tool the agent calls has real-world consequences (sends/charges/deletes), gate it for human approval — see ai-approval-tool.\n//~ deferUntil: entity-write\n//~ lang: ts\n//~ entity: assistant\n//~ steps:\n//~ THE TOOL. A TOOL is just a normal pikkuFunc the agent is allowed to call. Each tool\n//~ goes in its OWN *.function.ts (one func per file) and the agent in its own\n//~ *.agent.ts; import the tool into the agent file WITH the .js extension\n//~ (`import { suggestPriority } from '../functions/suggest-priority.function.js'`) and\n//~ pass the imported reference to `tools: [...]`. A tool needs `expose: true` (it is\n//~ also a real RPC) and, like every function, its I/O types come ONLY from the zod\n//~ schemas.\n//~\n//~ ⚠️ EVERY name in `tools: [...]` MUST have a matching `import` at the TOP of the AGENT\n//~ file — this is the #1 agent-wiring loop. The natural move is to REUSE an existing\n//~ CRUD function as a tool (e.g. \"the assistant can list animals\" → your\n//~ `listAnimals`), but a bare identifier with no import fails codegen with\n//~ `[PKU124] AI agent '<name>' tools array contains identifier 'listAnimals' that\n//~ could not be resolved to a pikkuFunc`. The fix is ALWAYS to add the import\n//~ (`import { listAnimals } from '../functions/list-animals.function.js'`), never to\n//~ drop or rename the tool. If PKU124 lists 5 tools, you are missing 5 imports — add\n//~ them ALL in one edit, then `pikku all` ONCE (do not re-verify per import).\n//~ A reused CRUD func also needs `expose: true` on its own declaration to be callable.\n//~\n//~ The tool does its work DIRECTLY in `func` via the injected services (kysely, …)\n//~ scoped to session.userId — read/write the DB there. The agent decides WHEN to call\n//~ it; you decide WHAT it does, so swap the shipped heuristic for whatever the domain\n//~ needs (a query, a write, an external call). Do NOT proxy to another function\n//~ through `rpc` / `rpc.exposed()` — there is no such API in a tool; if a tool shares\n//~ logic with a CRUD function, extract a lib helper both call.\n//~\n//~ THE AGENT. Listing it is all you need — pikku generates the HTTP routes.\n//~ ⚠️ CRITICAL — THE AGENT'S ADDRESS IS ITS EXPORTED CONST NAME, NOT `name`. pikku\n//~ registers `addAIAgent('<exportedConstName>', ...)` and serves the routes off that\n//~ SAME identifier:\n//~ POST /rpc/agent/<exportedConstName> (non-streaming)\n//~ POST /rpc/agent/<exportedConstName>/stream (SSE — what the chat UI uses)\n//~ The `name` field is human-facing metadata ONLY — it is NOT the URL segment and NOT\n//~ what the frontend calls. To avoid the \"AI agent not found\" 500, keep the exported\n//~ const name and `name` IDENTICAL (one camelCase identifier for both), and the\n//~ frontend PikkuAgentChat `agentName` MUST equal that exported const name. As\n//~ written the export is `assistant` and `name: 'assistant'` — if you rename it for\n//~ your domain (e.g. `todoAssistant`), set `name: 'todoAssistant'` too and pass\n//~ `agentName=\"todoAssistant\"` in the chat page. NEVER a kebab-case `name` that\n//~ differs from the export — that is exactly what breaks the assistant.\n//~\n//~ `model` MUST be provider-prefixed — Fabric routes everything through one LiteLLM\n//~ proxy, so use `openai/gpt-5.6-luna` (a bare alias fails). No API key to set up:\n//~ Fabric injects the AI credentials for the sandbox automatically.\n//~ `goal` is the system prompt. `tools` is the list of pikkuFuncs it may call — add\n//~ EVERY tool the assistant should be able to call, each with its import.\n\n// ===== FILE: packages/functions/src/functions/suggest-priority.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n\nexport const SuggestPriorityInput = z.object({\n title: z.string(),\n notes: z.string().optional(),\n})\nexport const SuggestPriorityOutput = z.object({\n priority: z.enum(['low', 'medium', 'high']),\n reason: z.string(),\n})\n\nexport const suggestPriority = pikkuFunc({\n expose: true,\n auth: true,\n description: 'Suggest a priority for a task from its title/notes.',\n input: SuggestPriorityInput,\n output: SuggestPriorityOutput,\n func: async ({ kysely }, input, { session }) => {\n void kysely\n void session\n const urgent = /urgent|asap|today|critical|overdue/i.test(`${input.title} ${input.notes ?? ''}`)\n return {\n priority: urgent ? 'high' : 'medium',\n reason: urgent ? 'Time-sensitive wording.' : 'No urgency signals detected.',\n }\n },\n})\n\n// ===== FILE: packages/functions/src/agents/assistant.agent.ts =====\nimport { pikkuAgent } from '#pikku/agent'\nimport { suggestPriority } from '../functions/suggest-priority.function.js'\n\nexport const assistant = pikkuAgent({\n name: 'assistant',\n description: 'In-app AI helper for this app’s domain.',\n goal: [\n 'You are the AI helper built into this app. Be concise and helpful.',\n 'Use the tools you are given to act on real data instead of guessing.',\n 'When the user asks for something a tool can do, call the tool — do not just describe it.',\n ].join('\\n'),\n model: 'openai/gpt-5.6-luna',\n tools: [suggestPriority],\n maxSteps: 6,\n})\n" }, { "name": "ai-approval-tool", "title": "Agent tool that needs HUMAN APPROVAL before it runs (human-in-the-loop) — approvalRequired + approvalDescription", "when": "An AI agent (see ai-agent) must be able to call a tool that has REAL-WORLD CONSEQUENCES — sends an email/message, charges a card, deletes records, posts publicly, moves money. You want the model to DECIDE to call it, but a HUMAN to confirm before it actually executes. This is the safe variant of an ai-agent tool: same pikkuFunc, plus `approvalRequired: true` and an `approvalDescription` so the user sees exactly what will happen and clicks approve/deny. Read-only lookups do NOT need this — only side-effecting tools.", "lang": "ts", "entity": "", "deferUntil": "entity-write", "requires": [], "steps": "═══ CLIENT SIDE — approval surfaces in the chat UI ═══\nThe PikkuAgentChat component renders the pending tool call with your\napprovalDescription string and Approve / Deny buttons; approving resumes the run and\nthe tool executes, denying tells the model it was rejected. You do NOT hand-roll an\napproval endpoint — the agent stream carries the approval round-trip. Just point the\nchat at agentName=\"billingAssistant\" (see the ai-agent client snippet).", "source": "packages/cli/examples/ai/ai-approval-tool.ts", "content": "//~ name: ai-approval-tool\n//~ title: Agent tool that needs HUMAN APPROVAL before it runs (human-in-the-loop) — approvalRequired + approvalDescription\n//~ when: An AI agent (see ai-agent) must be able to call a tool that has REAL-WORLD CONSEQUENCES — sends an email/message, charges a card, deletes records, posts publicly, moves money. You want the model to DECIDE to call it, but a HUMAN to confirm before it actually executes. This is the safe variant of an ai-agent tool: same pikkuFunc, plus `approvalRequired: true` and an `approvalDescription` so the user sees exactly what will happen and clicks approve/deny. Read-only lookups do NOT need this — only side-effecting tools.\n//~ deferUntil: entity-write\n//~ lang: ts\n\n//~ steps:\n//~ ═══ CLIENT SIDE — approval surfaces in the chat UI ═══\n//~ The PikkuAgentChat component renders the pending tool call with your\n//~ approvalDescription string and Approve / Deny buttons; approving resumes the run and\n//~ the tool executes, denying tells the model it was rejected. You do NOT hand-roll an\n//~ approval endpoint — the agent stream carries the approval round-trip. Just point the\n//~ chat at agentName=\"billingAssistant\" (see the ai-agent client snippet).\n// ===== FILE: packages/functions/src/functions/send-invoice-email.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n\nexport const SendInvoiceEmailInput = z.object({\n to: z.string().email(),\n amountCents: z.number().int().positive(),\n note: z.string().optional(),\n})\nexport const SendInvoiceEmailOutput = z.object({\n sent: z.boolean(),\n})\n\n//~ A NORMAL agent tool (a pikkuFunc with expose: true), but marked as gated:\n//~ • approvalRequired: true — the agent runner does NOT execute this tool when the\n//~ model calls it; it PAUSES and emits an approval request to the chat UI. The tool\n//~ only runs after the user approves (deny = the model is told it was rejected and\n//~ continues without the side effect). This is the human-in-the-loop switch.\n//~ • approvalDescription — a resolver `(services, input) => Promise<string>` that turns\n//~ the model's chosen ARGS into a plain-language confirmation string the user reads\n//~ BEFORE approving. Make it specific and include the consequential values (who,\n//~ how much) so \"Approve?\" is an informed choice, not a blind yes. It can hit\n//~ services (e.g. look up a name) — keep it cheap, it runs on every proposed call.\nexport const sendInvoiceEmail = pikkuFunc({\n expose: true, //~ exposes the RPC AND makes it callable by the agent as a tool\n auth: true,\n description: 'Email an invoice to a customer. Side-effecting — requires approval.',\n input: SendInvoiceEmailInput,\n output: SendInvoiceEmailOutput,\n approvalRequired: true,\n approvalDescription: async (_services, input: z.infer<typeof SendInvoiceEmailInput>) =>\n `Email an invoice for $${(input.amountCents / 100).toFixed(2)} to ${input.to}?`,\n func: async ({ emailService }, input, { session }) => {\n //~ By the time this BODY runs the user has ALREADY approved — so just do the work.\n //~ Do the real side effect via injected services scoped to session.userId.\n void session\n await emailService.sendEmail({\n to: input.to,\n subject: 'Your invoice',\n //~ A real template lives in the email scaffold; this is illustrative.\n text: input.note ?? `Amount due: $${(input.amountCents / 100).toFixed(2)}`,\n } as Parameters<typeof emailService.sendEmail>[0])\n return { sent: true }\n },\n})\n\n// ===== FILE: packages/functions/src/agents/billing-assistant.agent.ts =====\nimport { pikkuAgent } from '#pikku/agent'\nimport { sendInvoiceEmail } from '../functions/send-invoice-email.function.js'\n\n//~ The agent that may call it. It is defined EXACTLY like a normal ai-agent — the\n//~ approval gating lives on the TOOL, not the agent. The model can propose the call\n//~ whenever it makes sense; the runner enforces the human gate. Mix gated and un-gated\n//~ tools freely (read-only tools run immediately; this one waits for approval).\nexport const billingAssistant = pikkuAgent({\n name: 'billingAssistant', //~ <-- IDENTICAL to the exported const; that identifier is the route AND the frontend agentName\n description: 'Helps staff prepare and send customer invoices.',\n goal: [\n 'You help staff manage billing for this app.',\n 'When the user asks to bill or email a customer, call sendInvoiceEmail with the right amount.',\n 'The user will be asked to approve before any email is sent — do not claim it was sent until the tool returns.',\n ].join('\\n'),\n model: 'openai/gpt-5.6-luna',\n tools: [sendInvoiceEmail],\n maxSteps: 6,\n})\n" }, { "name": "ai-extract", "title": "Extract STRUCTURED data from free text (parse/classify/tag → typed JSON) — a one-shot declared agent", "when": "The app has UNSTRUCTURED text and needs TYPED FIELDS out of it — parse a pasted email/message into { sender, intent, dueDate }, classify a support ticket into a category + urgency, pull action items out of a meeting note, tag/score/summarize any blob into a fixed shape. A DECLARED one-shot agent (no tools, one step) called from a normal RPC, NOT a chat assistant (ai-agent) and NOT prose. For text off an IMAGE use ai-vision; for a scan/PDF use @pikku/addon-mistral ocrProcess then feed the text here.", "lang": "ts", "entity": "", "deferUntil": "entity-write", "requires": [], "steps": "═══ CLIENT SIDE — parse a pasted message ═══\nusePikkuMutation with isPending/error for the button + INLINE feedback — never a toast,\nnever useState for loading/error:\n\n const extract = usePikkuMutation('extractFields')\n // <Button loading={extract.isPending} onClick={() => extract.mutate({ text })}>\n // {extract.error && <Text c=\"red\">{asI18n(extract.error.message)}</Text>}\n // {extract.data && <Badge>{extract.data.category}</Badge>} // typed — category/urgency/…", "source": "packages/cli/examples/ai/ai-extract.ts", "content": "//~ name: ai-extract\n//~ title: Extract STRUCTURED data from free text (parse/classify/tag → typed JSON) — a one-shot declared agent\n//~ when: The app has UNSTRUCTURED text and needs TYPED FIELDS out of it — parse a pasted email/message into { sender, intent, dueDate }, classify a support ticket into a category + urgency, pull action items out of a meeting note, tag/score/summarize any blob into a fixed shape. A DECLARED one-shot agent (no tools, one step) called from a normal RPC, NOT a chat assistant (ai-agent) and NOT prose. For text off an IMAGE use ai-vision; for a scan/PDF use @pikku/addon-mistral ocrProcess then feed the text here.\n//~ deferUntil: entity-write\n//~ lang: ts\n\n//~ steps:\n//~ ═══ CLIENT SIDE — parse a pasted message ═══\n//~ usePikkuMutation with isPending/error for the button + INLINE feedback — never a toast,\n//~ never useState for loading/error:\n//~\n//~ const extract = usePikkuMutation('extractFields')\n//~ // <Button loading={extract.isPending} onClick={() => extract.mutate({ text })}>\n//~ // {extract.error && <Text c=\"red\">{asI18n(extract.error.message)}</Text>}\n//~ // {extract.data && <Badge>{extract.data.category}</Badge>} // typed — category/urgency/…\n// ===== FILE: packages/functions/src/agents/field-extractor.agent.ts =====\nimport { z } from 'zod'\nimport { pikkuAgent } from '#pikku/agent'\n\n//~ THE shape you want out. This ONE z.object is the single source of truth: it is the\n//~ agent's `output`, so the runner constrains the model to it AND `rpc.agent.run` comes\n//~ back typed as it — no second hand-written JSON Schema to drift. Make fields specific:\n//~ enums for categories, describe() to steer the model, .nullable() for \"might be absent\"\n//~ so the model returns null instead of hallucinating. Swap these for your domain.\nexport const ExtractedFields = z.object({\n category: z\n .enum(['bug', 'billing', 'feature-request', 'question', 'other'])\n .describe('The single best category for this message.'),\n urgency: z.enum(['low', 'medium', 'high']).describe('How time-sensitive it is.'),\n summary: z.string().describe('A one-sentence neutral summary.'),\n actionItems: z.array(z.string()).describe('Concrete follow-up actions, empty if none.'),\n dueDate: z.string().nullable().describe('ISO date if the text names a deadline, else null.'),\n})\n\n//~ EVERY text-generating AI call in the app is a DECLARED agent — including a one-shot\n//~ like this one, which is why there is no `agentRunner.run(...)` anywhere here. Not\n//~ style: declaring it registers the agent in pikku's generated meta, so the call gets a\n//~ thread and a recorded run row, and it shows up in the app's agent view with its\n//~ inputs, outputs and token usage. An inline runner call is invisible to all of that.\n//~\n//~ ONE-SHOT = `tools: []` + `maxSteps: 1` + `toolChoice: 'none'` + an `output` schema.\n//~ That is the whole difference from the chat assistant in the ai-agent scaffold: it\n//~ answers once, in your shape, and never calls a tool.\n//~\n//~ CRITICAL — THE AGENT'S ADDRESS IS ITS EXPORTED CONST NAME, NOT `name`. pikku registers\n//~ `addAIAgent('<exportedConstName>', ...)`, and that is the string you pass to\n//~ `rpc.agent.run(...)`. Keep the exported const and `name` IDENTICAL (one camelCase\n//~ identifier for both) or the call 500s with \"AI agent not found\".\n//~\n//~ `model` MUST be provider-prefixed — Fabric routes everything through one LiteLLM proxy,\n//~ so `openai/gpt-5.6-luna` (cheap, fine for extraction), `openai/gemini-pro-latest` or\n//~ `anthropic/claude-opus-4-8`; a bare alias fails. ⚠️ A reasoning model like `deepseek/*`\n//~ may REJECT JSON-schema structured output through the proxy — stay on an OpenAI/Anthropic\n//~ route whenever you declare an `output`. No API key to set up: Fabric injects the AI\n//~ credentials. This declaration is the ONE place the model is named — do not add a\n//~ defineVariable for it and do not pass a `model` at the call site: the generated type\n//~ for `rpc.agent.run` accepts only `{ message, threadId, resourceId }`.\nexport const fieldExtractor = pikkuAgent({\n name: 'fieldExtractor', //~ <-- keep IDENTICAL to the exported const name above\n description: 'Extracts structured, typed fields from a blob of free text.',\n goal: [\n 'You extract structured fields from the text the user sends.',\n 'Use only what the text supports — never invent a value.',\n 'When the text does not name something, return null or an empty list for it.',\n ].join('\\n'),\n model: 'openai/gpt-5.6-luna',\n output: ExtractedFields, //~ makes the run return a validated object instead of prose\n tools: [], //~ one-shot: nothing to call\n maxSteps: 1,\n toolChoice: 'none',\n})\n\n// ===== FILE: packages/functions/src/functions/extract-fields.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n//~ The agent file owns the schema; import it rather than restating the shape. The `.js`\n//~ extension is required on the import, same as for agent tools.\nimport { ExtractedFields } from '../agents/field-extractor.agent.js'\n\nexport const ExtractInput = z.object({\n //~ The raw text to parse. Keep the caller in control of what goes in.\n text: z.string().min(1),\n})\n//~ Output IS the extracted shape — the function returns exactly ExtractedFields.\nexport const ExtractOutput = ExtractedFields\n\n//~ A thin RPC over the agent, so the frontend calls one typed function instead of the\n//~ agent route directly — and so the extraction can be called from a create/update\n//~ function later without going through HTTP. `rpc` is on the THIRD parameter, never on\n//~ services. There is no `agentRunner` here and no optional-service guard to write:\n//~ the agent runner is the platform's problem, not this function's.\nexport const extractFields = pikkuFunc({\n expose: true,\n auth: true,\n readonly: true, //~ pure read — parses text, writes nothing\n description: 'Extract structured, typed fields from a blob of free text.',\n input: ExtractInput,\n output: ExtractOutput,\n func: async (_services, input, { rpc, session }) => {\n //~ Every run belongs to a thread. A one-shot has no conversation to continue, so mint\n //~ a fresh thread per call — the runner creates it — and scope it to the user with\n //~ resourceId, which is what files the run under them in the agent view.\n //~ crypto.randomUUID() works in the sandbox (bun/node) AND a deployed CF Worker.\n //~ `result` is typed as ExtractedFields because the agent declared it as its output.\n const { result } = await rpc.agent.run('fieldExtractor', {\n message: input.text,\n threadId: crypto.randomUUID(),\n resourceId: session.userId,\n })\n //~ Parse the result back through the SAME schema so a malformed model response fails\n //~ loudly here (typed) rather than downstream.\n return ExtractedFields.parse(result)\n },\n})\n" }, { "name": "ai-image", "title": "Generate an image from a text prompt (text → image) — via agentRunner.generateImage, NEEDS an image model wired", "when": "The app must CREATE an image from a description — generate an avatar, illustration, product mockup, thumbnail, or marketing visual from a prompt. NOT for understanding an existing image (that is ai-vision) and NOT for editing text (that is a normal agent). One-shot backend call that returns image bytes.", "lang": "ts", "entity": "", "deferUntil": "entity-write", "requires": [], "steps": "═══ CLIENT SIDE — generate + preview ═══\nusePikkuMutation with isPending/error for the button + INLINE feedback — never a toast:\n\n const gen = usePikkuMutation('generateImage')\n // <Button loading={gen.isPending} onClick={() => gen.mutate({ prompt })}>\n // {gen.error && <Text c=\"red\">{asI18n(gen.error.message)}</Text>}\n // {gen.data && <Image src={gen.data.dataUrl} />}", "source": "packages/cli/examples/ai/ai-image.ts", "content": "//~ name: ai-image\n//~ title: Generate an image from a text prompt (text → image) — via agentRunner.generateImage, NEEDS an image model wired\n//~ when: The app must CREATE an image from a description — generate an avatar, illustration, product mockup, thumbnail, or marketing visual from a prompt. NOT for understanding an existing image (that is ai-vision) and NOT for editing text (that is a normal agent). One-shot backend call that returns image bytes.\n//~ deferUntil: entity-write\n//~ lang: ts\n//~ steps:\n//~ ═══ CLIENT SIDE — generate + preview ═══\n//~ usePikkuMutation with isPending/error for the button + INLINE feedback — never a toast:\n//~\n//~ const gen = usePikkuMutation('generateImage')\n//~ // <Button loading={gen.isPending} onClick={() => gen.mutate({ prompt })}>\n//~ // {gen.error && <Text c=\"red\">{asI18n(gen.error.message)}</Text>}\n//~ // {gen.data && <Image src={gen.data.dataUrl} />}\n// ===== FILE: packages/functions/src/functions/generate-image.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\nimport { defineVariable } from '#pikku/variables'\n\n//~ ⚠️ READ THIS BEFORE COPYING — image generation is NOT free like vision/extract.\n//~ Text→image needs a DEDICATED image model, and `generateImage` is an OPTIONAL method\n//~ on the AI runner. Fabric's DEFAULT LiteLLM proxy routes CHAT models only (no image\n//~ route), and every provider prefix resolves to that same proxy — so on a\n//~ stock sandbox `agentRunner.generateImage` THROWS. To enable it you must (a) route\n//~ an image model in your LiteLLM config (e.g. an `gpt-image-1` / `dall-e-3` model_name)\n//~ AND (b) register that provider with an image-model factory on the runner, OR wire a\n//~ provider addon that exposes image generation. That is the \"which addon did you choose\"\n//~ decision — call it out to the user; do not silently assume it is available.\n\n//~ Un-pinned model id of an image model routed in the proxy. Change to whatever\n//~ model_name you actually registered.\ndefineVariable({\n name: 'AI_IMAGE_MODEL',\n displayName: 'Image model',\n description: 'Provider-prefixed image-generation model id (e.g. openai/gpt-image-1).',\n variableId: 'AI_IMAGE_MODEL',\n schema: z.string().default('openai/gpt-image-1'),\n})\n\nexport const GenerateImageInput = z.object({\n prompt: z.string().min(1).describe('What to draw.'),\n //~ Common aspect ratios; the model maps these to a supported size. Optional.\n aspectRatio: z.enum(['1:1', '16:9', '9:16', '4:3', '3:4']).default('1:1'),\n})\nexport const GenerateImageOutput = z.object({\n //~ The image as a data URL (`data:<mediaType>;base64,<...>`) so the client can render\n //~ it directly. For anything you keep, PERSIST it instead: upload the bytes via the\n //~ file-upload scaffold and return the stored URL — do not stuff large images through\n //~ every response. This inline form is for preview/one-off use.\n dataUrl: z.string(),\n mediaType: z.string(),\n})\n\n//~ Destructuring `agentRunner` tags this unit with the `ai-model` capability (keeps\n//~ the AI SDK bundled) and gives us generateImage. It is optional — guard it.\nexport const generateImage = pikkuFunc({\n expose: true,\n auth: true,\n readonly: true, //~ pure generation — returns an image, writes nothing itself\n description: 'Generate an image from a text prompt.',\n input: GenerateImageInput,\n output: GenerateImageOutput,\n func: async ({ agentRunner, variables }, input) => {\n if (!agentRunner) {\n throw new Error('agentRunner not configured — AI is unavailable in this stage')\n }\n if (!agentRunner.generateImage) {\n //~ THE addon-dependent path (see the warning at top). Fail loud and actionable.\n throw new Error(\n 'Image generation is not enabled: the AI runner has no image model. Route an ' +\n 'image model (e.g. gpt-image-1) in your LiteLLM config and register it in ' +\n 'the runner, or wire a provider addon that generates images.',\n )\n }\n const model = await variables.get('AI_IMAGE_MODEL')\n const { images } = await agentRunner.generateImage({\n model,\n prompt: input.prompt,\n aspectRatio: input.aspectRatio,\n n: 1,\n })\n const image = images[0]\n if (!image) {\n throw new Error('image model returned no image')\n }\n return {\n dataUrl: `data:${image.mediaType};base64,${image.base64}`,\n mediaType: image.mediaType,\n }\n },\n})\n" }, { "name": "ai-speech", "title": "Synthesize speech from text (text → audio, TTS) — via agentRunner.generateSpeech", "when": "The app must turn TEXT into spoken AUDIO — read a message aloud, voice a notification, narrate content, produce a voice reply. The mirror of ai-transcribe (audio → text). NOT for transcribing audio (that is ai-transcribe) and NOT for music. One-shot backend call that returns audio bytes.", "lang": "ts", "entity": "", "deferUntil": "entity-write", "requires": [], "steps": "═══ CLIENT SIDE — synthesize + play ═══\nusePikkuMutation with isPending/error for the button + INLINE feedback — never a toast:\n\n const speak = usePikkuMutation('synthesizeSpeech')\n // <Button loading={speak.isPending} onClick={() => speak.mutate({ text })}>\n // {speak.error && <Text c=\"red\">{asI18n(speak.error.message)}</Text>}\n // {speak.data && <audio controls src={speak.data.dataUrl} />}", "source": "packages/cli/examples/ai/ai-speech.ts", "content": "//~ name: ai-speech\n//~ title: Synthesize speech from text (text → audio, TTS) — via agentRunner.generateSpeech\n//~ when: The app must turn TEXT into spoken AUDIO — read a message aloud, voice a notification, narrate content, produce a voice reply. The mirror of ai-transcribe (audio → text). NOT for transcribing audio (that is ai-transcribe) and NOT for music. One-shot backend call that returns audio bytes.\n//~ deferUntil: entity-write\n//~ lang: ts\n//~ steps:\n//~ ═══ CLIENT SIDE — synthesize + play ═══\n//~ usePikkuMutation with isPending/error for the button + INLINE feedback — never a toast:\n//~\n//~ const speak = usePikkuMutation('synthesizeSpeech')\n//~ // <Button loading={speak.isPending} onClick={() => speak.mutate({ text })}>\n//~ // {speak.error && <Text c=\"red\">{asI18n(speak.error.message)}</Text>}\n//~ // {speak.data && <audio controls src={speak.data.dataUrl} />}\n// ===== FILE: packages/functions/src/functions/synthesize-speech.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\nimport { defineVariable } from '#pikku/variables'\n\n//~ Text→speech WORKS OUT OF THE BOX on a Fabric sandbox — no addon, no API key, no\n//~ services.ts edit. The proxy routes `kokoro-82m` with `mode: audio_speech`, and the\n//~ platform wires each provider as an object carrying a `speech` method. (An older\n//~ revision said the opposite; it predates both. The guard below stays only for an app\n//~ running against some OTHER AI wiring.)\n//~\n//~ ⚠️ Kokoro's VOICES ARE PER-LANGUAGE, and it does not refuse a language it cannot\n//~ speak — handed Arabic with the default American-English voice it reads out letter\n//~ names (\"Arabic meem, Arabic ra\") for 24 seconds. Pick the voice from the text's\n//~ script, and refuse a script you have no voice for; a proxy entry cannot express that,\n//~ so it is this function's job.\n\n//~ The TTS model, as config rather than a literal so a stage can swap it. `openai/` picks\n//~ the OpenAI-compatible HANDLER, not the vendor — kokoro-82m is served by DeepInfra.\ndefineVariable({\n name: 'AI_SPEECH_MODEL',\n displayName: 'Speech model',\n description: 'Provider-prefixed text-to-speech model id routed by the proxy.',\n variableId: 'AI_SPEECH_MODEL',\n schema: z.string().default('openai/kokoro-82m'),\n})\n\nexport const SynthesizeSpeechInput = z.object({\n text: z.string().min(1).describe('The text to speak.'),\n //~ Voice name is provider-specific (e.g. OpenAI: alloy, echo, fable, …). Optional —\n //~ the model uses its default when omitted.\n voice: z.string().optional(),\n})\nexport const SynthesizeSpeechOutput = z.object({\n //~ The audio as a data URL (`data:<mediaType>;base64,<...>`) so the client can play it\n //~ directly. For anything you keep, PERSIST it instead: upload the bytes via the\n //~ file-upload scaffold and return the stored URL — do not stream large audio through\n //~ every response. This inline form is for preview/one-off playback.\n dataUrl: z.string(),\n mediaType: z.string(),\n})\n\n//~ Destructuring `agentRunner` tags this unit with the `ai-model` capability (keeps\n//~ the AI SDK bundled) and gives us generateSpeech. It is optional — guard it.\nexport const synthesizeSpeech = pikkuFunc({\n expose: true,\n auth: true,\n readonly: true, //~ pure synthesis — returns audio, writes nothing itself\n description: 'Synthesize spoken audio from text (text-to-speech).',\n input: SynthesizeSpeechInput,\n output: SynthesizeSpeechOutput,\n func: async ({ agentRunner, variables }, input) => {\n if (!agentRunner) {\n throw new Error('agentRunner not configured — AI is unavailable in this stage')\n }\n if (!agentRunner.generateSpeech) {\n //~ Not reachable on a stock Fabric sandbox — only if this app replaced the AI\n //~ wiring. Fail loud and actionable rather than 500.\n throw new Error(\n 'Text-to-speech is not enabled: this app’s AI runner has no speech model. ' +\n 'The runner needs a provider exposing a `speech` method ' +\n '(a bare chat-model function only satisfies language models).',\n )\n }\n const model = await variables.get('AI_SPEECH_MODEL')\n const { audio } = await agentRunner.generateSpeech({\n model,\n text: input.text,\n voice: input.voice,\n })\n return {\n dataUrl: `data:${audio.mediaType};base64,${audio.base64}`,\n mediaType: audio.mediaType,\n }\n },\n})\n" }, { "name": "ai-transcribe", "title": "Transcribe audio → text (speech-to-text) — via agentRunner.transcribe, NEEDS an STT model wired", "when": "The app must turn AUDIO into text — transcribe a voice note, recording, podcast, meeting, or call; generate captions/subtitles from an audio track; any \"speech-to-text\". The audio usually arrives via the file-upload scaffold (upload → getFileViewUrl → fetch the bytes here). NOT for text→speech (that is agentRunner.generateSpeech) and NOT for reading text off an image (that is the ai-vision / OCR scaffold).", "lang": "ts", "entity": "", "deferUntil": "entity-write", "requires": [], "steps": "═══ CLIENT SIDE — transcribe an uploaded recording ═══\nUpload the audio (file-upload scaffold), then feed the returned url here.\nusePikkuMutation with isPending/error for the button + INLINE feedback — never a toast:\n\n const transcribe = usePikkuMutation('transcribeAudio')\n // <Button loading={transcribe.isPending} onClick={() => transcribe.mutate({ audioUrl })}>\n // {transcribe.error && <Text c=\"red\">{asI18n(transcribe.error.message)}</Text>}\n // {transcribe.data && <Text>{transcribe.data.text}</Text>}", "source": "packages/cli/examples/ai/ai-transcribe.ts", "content": "//~ name: ai-transcribe\n//~ title: Transcribe audio → text (speech-to-text) — via agentRunner.transcribe, NEEDS an STT model wired\n//~ when: The app must turn AUDIO into text — transcribe a voice note, recording, podcast, meeting, or call; generate captions/subtitles from an audio track; any \"speech-to-text\". The audio usually arrives via the file-upload scaffold (upload → getFileViewUrl → fetch the bytes here). NOT for text→speech (that is agentRunner.generateSpeech) and NOT for reading text off an image (that is the ai-vision / OCR scaffold).\n//~ deferUntil: entity-write\n//~ lang: ts\n//~ steps:\n//~ ═══ CLIENT SIDE — transcribe an uploaded recording ═══\n//~ Upload the audio (file-upload scaffold), then feed the returned url here.\n//~ usePikkuMutation with isPending/error for the button + INLINE feedback — never a toast:\n//~\n//~ const transcribe = usePikkuMutation('transcribeAudio')\n//~ // <Button loading={transcribe.isPending} onClick={() => transcribe.mutate({ audioUrl })}>\n//~ // {transcribe.error && <Text c=\"red\">{asI18n(transcribe.error.message)}</Text>}\n//~ // {transcribe.data && <Text>{transcribe.data.text}</Text>}\n// ===== FILE: packages/functions/src/functions/transcribe-audio.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n\n//~ Speech-to-text WORKS OUT OF THE BOX on a Fabric sandbox — no addon, no API key, no\n//~ services.ts edit. Use this scaffold directly; do not ask the user to choose a\n//~ transcription provider, and do not reach for `@pikku/addon-mistral` instead.\n//~\n//~ Two things make it work, and both are already done for you:\n//~ • the LiteLLM proxy routes real ASR models alongside the chat ones —\n//~ `whisper-large-v3-turbo` and `nemotron-asr` (both DeepInfra) and\n//~ `gpt-4o-transcribe` (OpenAI), each carrying `mode: audio_transcription`;\n//~ • the platform wires each provider as an OBJECT with a `transcription`\n//~ method, not a bare chat-model function, so the runner can resolve a\n//~ transcription model rather than only a language one. Nothing to do in\n//~ services.ts — the runner arrives injected.\n//~\n//~ (An older revision of this file claimed the opposite. It was written when the\n//~ template registered plain chat-model functions, which satisfy `language` and nothing\n//~ else — that is the \"provider does not support transcription models\" error. Both\n//~ halves are fixed; the guard below stays only so an app running against some OTHER\n//~ AI wiring fails with a sentence instead of a mystery 500.)\n\n//~ Provider-prefixed id of an ASR model the proxy routes. The `openai/` prefix picks the\n//~ HANDLER (the OpenAI-compatible `/v1/audio/transcriptions` shape) — it does not mean\n//~ OpenAI is the vendor; whisper-large-v3-turbo is served by DeepInfra behind the proxy.\n//~\n//~ Prefer whisper here over `nemotron-asr`: nemotron is a STREAMING model handed a whole\n//~ file, and it drops the tail of what it is given (\"I said yes\" → \"I said ye\"). Losing\n//~ the last words of a sentence is survivable in a live conversation and is not in a\n//~ transcript somebody will read back. Whisper's own failure runs the other way — it\n//~ answers silence with invented filler (\"Thank you.\") — so a recording that opens or\n//~ closes on room tone gets a line that was never spoken. Neither is fixable from the\n//~ caller; say which one you picked when you show the user the transcript.\nconst STT_MODEL = 'openai/whisper-large-v3-turbo'\n\nexport const TranscribeAudioInput = z.object({\n //~ A viewable audio URL — pass the one getFileViewUrl returns (see file-upload), or\n //~ any public audio URL. We fetch the bytes below; the STT API needs raw bytes.\n audioUrl: z.string().url(),\n})\nexport const TranscribeAudioOutput = z.object({\n text: z.string(),\n //~ Whisper-style outputs include per-segment timings — handy for captions. Optional\n //~ because a given STT model may not return them.\n language: z.string().optional(),\n durationInSeconds: z.number().optional(),\n})\n\n//~ Destructuring `agentRunner` tags this unit with the `ai-model` capability (keeps\n//~ the AI SDK bundled) and gives us the transcribe method — the same runner that powers\n//~ pikkuAgent. It is optional, so guard it.\nexport const transcribeAudio = pikkuFunc({\n expose: true,\n auth: true,\n readonly: true, //~ pure read — turns audio into text, writes nothing\n description: 'Transcribe an audio file to text (speech-to-text).',\n input: TranscribeAudioInput,\n output: TranscribeAudioOutput,\n func: async ({ agentRunner }, input) => {\n if (!agentRunner) {\n throw new Error('agentRunner not configured — AI is unavailable in this stage')\n }\n if (!agentRunner.transcribe) {\n //~ Not reachable on a stock Fabric sandbox — only if this app replaced the AI\n //~ wiring. Fail loud and actionable rather than 500.\n throw new Error(\n 'Speech-to-text is not enabled: this app’s AI runner has no transcription model. ' +\n 'The runner needs a provider exposing a `transcription` method ' +\n '(a bare chat-model function only satisfies language models).',\n )\n }\n //~ STT wants raw bytes, so fetch the audio and hand over a Uint8Array. `fetch` and\n //~ the Web streams API are available in the sandbox (bun/node) and a CF Worker.\n const res = await fetch(input.audioUrl)\n if (!res.ok) {\n throw new Error(`could not fetch audio (${res.status})`)\n }\n const audio = new Uint8Array(await res.arrayBuffer())\n\n const result = await agentRunner.transcribe({ model: STT_MODEL, audio })\n return {\n text: result.text,\n language: result.language,\n durationInSeconds: result.durationInSeconds,\n }\n },\n})\n" }, { "name": "ai-transcribe-flow", "title": "Upload → transcribe → stored transcript, as a durable workflow (the whole STT journey)", "when": "The app STORES recordings and their transcripts — a user uploads audio and later reads the transcript back (sessions, voice notes, call logs, interviews). This is the complete journey: the create RPC saves the row as 'processing' and starts the workflow; the step transcribes server-side and flips the row to 'ready'. Pair it with the file-upload scaffold (the client uploads first, then calls createRecording with the fileKey). Pick the plain {name: ai-transcribe} instead when the transcript is ephemeral — transcribed, shown, never persisted. Adapt the `recording` table/name to the app's domain entity; keep the shape.", "lang": "ts", "entity": "recording", "deferUntil": "entity-write", "requires": [], "steps": "═══ CLIENT SIDE — upload, create, poll ═══\n1. Upload via the file-upload scaffold (requestFileUpload → PUT bytes → fileKey).\n2. const create = usePikkuMutation('createRecording')\n create.mutate({ fileKey, fileName }) // row appears instantly as 'processing'\n3. Poll the list while anything is processing — usePikkuQuery('listRecordings', {},\n { refetchInterval: (q) => q.state.data?.recordings.some((r) => r.status === 'processing') ? 2000 : false })\n Render status inline (Badge: processing=blue, ready=green, failed=red) and the\n transcript on the detail view once ready. Errors inline via create.error — never a toast.", "source": "packages/cli/examples/ai/ai-transcribe-flow.ts", "content": "//~ name: ai-transcribe-flow\n//~ title: Upload → transcribe → stored transcript, as a durable workflow (the whole STT journey)\n//~ when: The app STORES recordings and their transcripts — a user uploads audio and later reads the transcript back (sessions, voice notes, call logs, interviews). This is the complete journey: the create RPC saves the row as 'processing' and starts the workflow; the step transcribes server-side and flips the row to 'ready'. Pair it with the file-upload scaffold (the client uploads first, then calls createRecording with the fileKey). Pick the plain {name: ai-transcribe} instead when the transcript is ephemeral — transcribed, shown, never persisted. Adapt the `recording` table/name to the app's domain entity; keep the shape.\n//~ deferUntil: entity-write\n//~ entity: recording\n//~ lang: ts\n//~ The SPLIT is load-bearing (same rules as {name: workflow}): the workflow lives in\n//~ src/workflows/*.workflow.ts, each step func in its own file, and the migration ships\n//~ the table the flow writes to. The client NEVER supplies transcript text — the server\n//~ computes it. A milestone that promises transcription is only met by this shape (the\n//~ build-complete gate checks for the agentRunner call).\n\n//~ steps:\n//~ ═══ CLIENT SIDE — upload, create, poll ═══\n//~ 1. Upload via the file-upload scaffold (requestFileUpload → PUT bytes → fileKey).\n//~ 2. const create = usePikkuMutation('createRecording')\n//~ create.mutate({ fileKey, fileName }) // row appears instantly as 'processing'\n//~ 3. Poll the list while anything is processing — usePikkuQuery('listRecordings', {},\n//~ { refetchInterval: (q) => q.state.data?.recordings.some((r) => r.status === 'processing') ? 2000 : false })\n//~ Render status inline (Badge: processing=blue, ready=green, failed=red) and the\n//~ transcript on the detail view once ready. Errors inline via create.error — never a toast.\n// ===== FILE: db/sqlite/0003-recording.sql =====\n//~ Renumber to the next free slot in db/sqlite/ (or db/postgres/ with that engine's\n//~ types) — migrations apply in filename order. After adding it: `pikku db migrate`\n//~ regenerates the kysely/zod types this code compiles against.\n-- One uploaded recording and its server-computed transcript. `status` is the\n-- lifecycle the UI polls: processing → ready | failed.\nCREATE TABLE recording (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL,\n file_key TEXT NOT NULL,\n file_name TEXT,\n status TEXT NOT NULL DEFAULT 'processing',\n transcript TEXT,\n duration_seconds INTEGER,\n error TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n// ===== FILE: packages/functions/src/functions/create-recording.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n\n//~ Called AFTER the file-upload scaffold's requestFileUpload + client PUT. Saves the\n//~ row in 'processing' and starts the durable workflow — the caller gets the row back\n//~ immediately and the UI polls status. No transcript field in the input, ever.\nexport const CreateRecordingInput = z.object({\n fileKey: z.string().min(1),\n fileName: z.string().optional(),\n})\nexport const CreateRecordingOutput = z.object({\n recording: z.object({\n id: z.string(),\n status: z.string(),\n fileName: z.string().nullable(),\n createdAt: z.string(),\n }),\n})\n\nexport const createRecording = pikkuFunc({\n expose: true,\n auth: true,\n description: 'Save an uploaded recording and start its transcription workflow.',\n input: CreateRecordingInput,\n output: CreateRecordingOutput,\n func: async ({ kysely }, input, { session, rpc }) => {\n const row = await kysely\n .insertInto('recording')\n .values({\n id: crypto.randomUUID(),\n userId: session!.userId,\n fileKey: input.fileKey,\n fileName: input.fileName ?? null,\n status: 'processing',\n })\n .returningAll()\n .executeTakeFirstOrThrow()\n await rpc.startWorkflow('transcribeRecordingWorkflow', { recordingId: row.id })\n return {\n recording: {\n id: row.id,\n status: row.status,\n fileName: row.fileName,\n createdAt: String(row.createdAt),\n },\n }\n },\n})\n\n// ===== FILE: packages/functions/src/functions/transcribe-recording-step.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\nimport { BUCKET } from '../lib/file-keys.js'\n\n//~ Provider-prefixed ASR model the proxy routes — pinned, never freehand a model id\n//~ (`whisper-1` etc. is NOT routed and 401s at runtime). Why whisper over nemotron-asr:\n//~ see the note in {name: ai-transcribe}.\nconst STT_MODEL = 'openai/whisper-large-v3-turbo'\n\nexport const TranscribeRecordingStepInput = z.object({ recordingId: z.string().min(1) })\nexport const TranscribeRecordingStepOutput = z.object({ status: z.string() })\n\nexport const transcribeRecordingStep = pikkuSessionlessFunc({\n expose: true, //~ steps must be registered RPCs so workflow.do can dispatch them\n description: 'Transcribe a stored recording and save the transcript on its row.',\n input: TranscribeRecordingStepInput,\n output: TranscribeRecordingStepOutput,\n func: async ({ kysely, content, agentRunner }, input) => {\n if (!agentRunner?.transcribe) {\n throw new Error('agentRunner not configured — AI is unavailable in this stage')\n }\n const row = await kysely\n .selectFrom('recording')\n .selectAll()\n .where('id', '=', input.recordingId)\n .executeTakeFirstOrThrow()\n\n //~ The STT API wants raw bytes, so sign a short-lived URL for our own stored file\n //~ and fetch it — the same signContentKey the file-upload scaffold's view RPC uses.\n const url = await content.signContentKey({\n bucket: BUCKET,\n contentKey: row.fileKey,\n dateLessThan: new Date(Date.now() + 3_600_000),\n })\n const res = await fetch(url)\n if (!res.ok) {\n //~ A missing/unreadable file is permanent for this row — mark it failed so the\n //~ UI stops polling, THEN throw so the workflow records the failure.\n await kysely\n .updateTable('recording')\n .set({ status: 'failed', error: `could not fetch audio (${res.status})` })\n .where('id', '=', input.recordingId)\n .execute()\n throw new Error(`could not fetch audio (${res.status})`)\n }\n const audio = new Uint8Array(await res.arrayBuffer())\n\n const result = await agentRunner.transcribe({ model: STT_MODEL, audio })\n await kysely\n .updateTable('recording')\n .set({\n status: 'ready',\n transcript: result.text,\n durationSeconds: result.durationInSeconds ?? null,\n })\n .where('id', '=', input.recordingId)\n .execute()\n return { status: 'ready' }\n },\n})\n\n// ===== FILE: packages/functions/src/workflows/transcribe-recording.workflow.ts =====\nimport { z } from 'zod'\nimport { pikkuWorkflowFunc } from '#pikku/workflow'\n\n//~ One durable step is the point: the container can restart mid-transcription and the\n//~ run resumes instead of leaving the row 'processing' forever. Chain follow-on AI here\n//~ as MORE steps — e.g. after transcribing, extract themes/tags from the transcript with\n//~ an {name: ai-extract} step func:\n//~ await workflow.do('Extract themes', 'extractRecordingThemes', { recordingId: data.recordingId })\nexport const TranscribeRecordingWorkflowInput = z.object({ recordingId: z.string().min(1) })\nexport const TranscribeRecordingWorkflowOutput = z.object({ status: z.string() })\n\nexport const transcribeRecordingWorkflow = pikkuWorkflowFunc({\n description: 'Transcribe an uploaded recording and store the transcript on its row.',\n input: TranscribeRecordingWorkflowInput,\n output: TranscribeRecordingWorkflowOutput,\n func: async (_services, data, { workflow }) => {\n const result = await workflow.do('Transcribe recording', 'transcribeRecordingStep', {\n recordingId: data.recordingId,\n })\n return { status: result.status }\n },\n})\n" }, { "name": "ai-vision", "title": "Analyze an image with a vision model (describe/caption/classify) — one-shot, via agentRunner", "when": "The app must UNDERSTAND an image and answer about it in natural language — describe/caption it, classify it, moderate it, answer \"is there a cat in this photo?\". One-shot backend call, NOT a chat assistant (that is the ai-agent scaffold) and NOT image GENERATION. For pulling the TEXT out of a scan/receipt/PDF use the ai-ocr scaffold (a cheaper, purpose-built OCR model), not this. The image usually comes from the file-upload scaffold (upload → getFileViewUrl → pass that url here).", "lang": "ts", "entity": "", "deferUntil": "entity-write", "requires": [], "steps": "", "source": "packages/cli/examples/ai/ai-vision.ts", "content": "//~ name: ai-vision\n//~ title: Analyze an image with a vision model (describe/caption/classify) — one-shot, via agentRunner\n//~ when: The app must UNDERSTAND an image and answer about it in natural language — describe/caption it, classify it, moderate it, answer \"is there a cat in this photo?\". One-shot backend call, NOT a chat assistant (that is the ai-agent scaffold) and NOT image GENERATION. For pulling the TEXT out of a scan/receipt/PDF use the ai-ocr scaffold (a cheaper, purpose-built OCR model), not this. The image usually comes from the file-upload scaffold (upload → getFileViewUrl → pass that url here).\n//~ deferUntil: entity-write\n//~ lang: ts\n// ===== FILE: packages/functions/src/functions/analyze-image.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n//~ The model is CONFIG, not a hardcoded literal — wire it once so it is swappable per\n//~ stage without touching this function. defineVariable/defineSecret come from the\n//~ generated leaves (see the wire-config scaffold); keep this in its OWN *.config.ts file.\nimport { defineVariable } from '#pikku/variables'\n\n//~ Un-pinned model: pick the vision model via a variable, with a vision-capable default.\n//~ The value MUST be provider-prefixed AND a model your LiteLLM proxy actually routes\n//~ WITH vision. On Fabric's default proxy that is `openai/gpt-5.6-luna` (cheap), `openai/gemini-pro-latest`,\n//~ or `anthropic/claude-opus-4-8` — a text-only route (e.g. a deepseek chat model) rejects\n//~ the image. Change the default per app; never assume a specific model exists.\ndefineVariable({\n name: 'AI_VISION_MODEL',\n displayName: 'Vision model',\n description: 'Provider-prefixed, vision-capable model id used for image analysis.',\n variableId: 'AI_VISION_MODEL',\n schema: z.string().default('openai/gpt-5.6-luna'),\n})\n\nexport const AnalyzeImageInput = z.object({\n //~ A viewable image URL — pass the one getFileViewUrl returns (see the file-upload\n //~ scaffold), or any public https image URL.\n imageUrl: z.string().url(),\n //~ What to ask about the image. Keep the caller in control of the question.\n question: z.string().min(1).default('Describe this image in one or two sentences.'),\n})\nexport const AnalyzeImageOutput = z.object({\n answer: z.string(),\n})\n\n//~ Destructuring `agentRunner` (the same VercelAgentRunner that powers pikkuAgent)\n//~ is what tags this unit with the `ai-model` capability so the AI SDK stays bundled —\n//~ a plain function that never touches it gets the SDK stubbed at build time. It is an\n//~ OPTIONAL core service, so guard it. No API key to set up — Fabric injects AI creds.\nexport const analyzeImage = pikkuFunc({\n expose: true,\n auth: true,\n readonly: true, //~ pure read — looks at an image, writes nothing\n description: 'Ask a vision model a free-form question about an image.',\n input: AnalyzeImageInput,\n output: AnalyzeImageOutput,\n func: async ({ agentRunner, variables }, input) => {\n if (!agentRunner) {\n //~ Only fires if the AI credentials were not injected (LITELLM_PROXY_URL /\n //~ LITELLM_API_KEY missing). Surface it — never swallow the error.\n throw new Error('agentRunner not configured — AI is unavailable in this stage')\n }\n const model = await variables.get('AI_VISION_MODEL')\n //~ One non-streaming completion. The image rides in the message `content` array as\n //~ an `image` part (url OR `{ data: '<base64>', mediaType }`). tools:[] +\n //~ toolChoice:'none' + maxSteps:1 = \"just answer, don't call tools\".\n const { text } = await agentRunner.run({\n model,\n instructions:\n 'You are a precise visual analyst. Answer only from what is visible in the image.',\n messages: [\n {\n id: crypto.randomUUID(), //~ works in the sandbox (bun/node) AND a deployed CF Worker\n role: 'user',\n content: [\n { type: 'text', text: input.question },\n { type: 'image', url: input.imageUrl }, //~ the vision part\n ],\n createdAt: new Date(),\n },\n ],\n tools: [],\n maxSteps: 1,\n toolChoice: 'none',\n })\n return { answer: text }\n },\n})\n\n//~ Need STRUCTURED analysis instead of prose (e.g. { hasReceipt: boolean, itemCount: number })?\n//~ Pass an `outputSchema` (a JSON Schema) to agentRunner.run and type output to a matching\n//~ z.object; run() fills `object` when tools is empty.\n//~\n//~ OCR (pulling verbatim text out of a scan/receipt/PDF) is a DIFFERENT job — a vision\n//~ prompt can do it in a pinch, but a purpose-built OCR model is cheaper and far more\n//~ accurate on dense documents. That belongs in a PROVIDER ADDON: `bun add @pikku/addon-mistral`,\n//~ `wireAddon({ name: 'mistral', package: '@pikku/addon-mistral' })`, then call it as an RPC —\n//~ `rpc.invoke('mistral:ocrProcess', { document: { type: 'document_url', documentUrl } })` for\n//~ plain text, or `mistral:ocrExtract` to OCR + pull structured fields against a JSON Schema in\n//~ one call. Same shape as @pikku/addon-whatsapp. Reach for that addon rather than forcing OCR\n//~ through this vision call. (The addon also exposes `mistral:textEmbedding` and\n//~ `mistral:audioTranscribe`, but do NOT reach for that one for audio: transcription\n//~ already works through the proxy with no addon and no key — see the ai-transcribe\n//~ scaffold.)\n\n//~ ═══ CLIENT SIDE — analyze an uploaded image ═══\n//~ First upload the file (file-upload scaffold: requestFileUpload → PUT bytes →\n//~ getFileViewUrl), then feed the returned url here. usePikkuMutation with\n//~ isPending/error for the button + INLINE feedback — never a toast, never useState:\n//~\n//~ const analyze = usePikkuMutation('analyzeImage')\n//~ // <Button loading={analyze.isPending} onClick={() => analyze.mutate({ imageUrl, question })}>\n//~ // {analyze.error && <Text c=\"red\">{asI18n(analyze.error.message)}</Text>}\n//~ // {analyze.data && <Text>{analyze.data.answer}</Text>}\n" }, { "name": "audit-log", "title": "Audit trail — record who changed what, and read it back", "when": "The app has a real audit/compliance requirement (finance, healthcare, admin tooling, anything where \"who changed this record\" is a feature). NOT needed by default — do not add an audit table unless the brief asks for one.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/data/audit-log.ts", "content": "//~ name: audit-log\n//~ title: Audit trail — record who changed what, and read it back\n//~ when: The app has a real audit/compliance requirement (finance, healthcare, admin tooling, anything where \"who changed this record\" is a feature). NOT needed by default — do not add an audit table unless the brief asks for one.\n//~ lang: ts\n\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n\n// ─────────────────────────────────────────────────────────────────────────────\n// STEP 1 — add the table. The template does NOT ship one; audit is opt-in.\n// Create db/sqlite/<next-free-number>-audit.sql (the files already in that\n// directory show which numbers are taken — they must be consecutive and gap-free) with\n// EXACTLY this DDL. The column names are fixed: the platform's audit service\n// writes these columns by name, so renaming any of them silently drops the field.\n//\n// CREATE TABLE IF NOT EXISTS audit (\n// audit_id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n// occurred_at TEXT NOT NULL DEFAULT (datetime('now')),\n// type TEXT NOT NULL,\n// source TEXT NOT NULL DEFAULT 'auto',\n// outcome TEXT,\n// function_id TEXT,\n// wire_type TEXT,\n// trace_id TEXT,\n// transaction_id TEXT,\n// query_id TEXT,\n// actor_user_id TEXT,\n// actor_org_id TEXT,\n// tables TEXT, -- JSON array of table names touched\n// changed_cols TEXT, -- JSON array of changed column names\n// event TEXT, -- custom event label\n// old TEXT, -- JSON: previous values\n// data TEXT -- JSON: new values / event payload\n// );\n//\n// CREATE INDEX IF NOT EXISTS idx_audit_occurred_at ON audit (occurred_at);\n// CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit (actor_user_id) WHERE actor_user_id IS NOT NULL;\n// CREATE INDEX IF NOT EXISTS idx_audit_function ON audit (function_id) WHERE function_id IS NOT NULL;\n//\n// Then run `pikku db migrate` — it applies the migration and regenerates the schema types.\n//\n// ⚠️ THE `audit` TABLE HAS NO CRUD. Fabric writes it for you — there is no create,\n// edit or delete screen for an audit row, and you must NEVER write\n// `insertInto('audit')` / `updateTable('audit')` in a function. An audit trail the app\n// can edit is not an audit trail. Rows arrive one of two ways, both below: `audit: true`\n// on a function (automatic capture) or `auditLog.write(...)` (an explicit domain event).\n// The only app-owned code that touches this table is a READ (step 3).\n//\n// ⚠️ READ THIS BEFORE YOU BUILD A UI ON IT — in the sandbox the audit SINK is a\n// no-op. services.ts resolves `existingServices?.audit ?? new NoopAuditService()`:\n// a DEPLOYED stage gets the platform's real sink injected, the sandbox does not.\n// So the table stays EMPTY here no matter how many records you create. That is\n// correct, not a bug. Do NOT \"fix\" it by rewriting the function, by inserting into\n// `audit` by hand, or by hunting for the row in the UI — an audit screen that\n// renders its empty state locally is DONE.\n// ─────────────────────────────────────────────────────────────────────────────\n\n//~ An audit trail readable by the people it audits is not one — so everything here is\n//~ gated. The gate is a SCOPE (a capability that depends only on the session), NOT a\n//~ role check: there is no `session.role`, and a hand-rolled `pikkuAuth` reading one\n//~ does not typecheck. Pull the `wire-scope` scaffold FIRST — it declares the scope\n//~ tree, and `auth-session` grants the scopes in mapSession. Scopes FAIL CLOSED: a\n//~ `scopes: [...]` on a function whose scope nobody was granted 403s everyone,\n//~ including the admin. Grant first, gate second.\n\n// STEP 2 — turn capture on for the functions worth auditing. `audit: true` is what\n// wraps kysely so every table write inside this function is recorded (old values,\n// changed columns, actor). Put it on MUTATIONS that matter — approvals, deletions,\n// permission and money changes — not on every list query.\nexport const updateInvoiceStatus = pikkuFunc({\n audit: true,\n expose: true,\n input: z.object({ invoiceId: z.string(), status: z.enum(['draft', 'approved', 'paid']) }),\n output: z.object({ invoiceId: z.string() }),\n scopes: ['admin:invoices:approve'], //~ the capability THIS action needs, not an audit scope\n func: async ({ kysely }, input) => {\n await kysely\n .updateTable('invoice')\n .set({ status: input.status })\n .where('invoiceId', '=', input.invoiceId)\n .execute()\n return { invoiceId: input.invoiceId }\n },\n})\n\n// A domain event the automatic capture can't infer (\"this invoice was exported to\n// the accountant\") is written explicitly. `auditLog` is always injected, but write()\n// only PERSISTS when this function set `audit: true` — without it, it warns and no-ops.\nexport const exportInvoice = pikkuFunc({\n audit: true,\n expose: true,\n input: z.object({ invoiceId: z.string() }),\n output: z.object({ ok: z.boolean() }),\n scopes: ['admin:invoices:export'],\n func: async ({ auditLog }, input, session) => {\n await auditLog.write({\n type: 'invoice.exported',\n source: 'explicit',\n metadata: { invoiceId: input.invoiceId, exportedBy: session.userId },\n })\n return { ok: true }\n },\n})\n\n// STEP 3 — read it back. Newest first, cursor-free (an audit screen is scanned, not\n// paged deep). The read is its own capability — reading the trail is not the same\n// privilege as performing the audited actions above.\nexport const listAuditEvents = pikkuFunc({\n expose: true,\n readonly: true,\n input: z.object({\n limit: z.number().int().min(1).max(200).default(50),\n actorUserId: z.string().optional(),\n }),\n output: z.object({\n events: z.array(\n z.object({\n auditId: z.string(),\n occurredAt: z.string(),\n type: z.string(),\n actorUserId: z.string().nullable(),\n tables: z.array(z.string()),\n changedCols: z.array(z.string()),\n }),\n ),\n }),\n scopes: ['admin:audit:read'],\n func: async ({ kysely }, input) => {\n let query = kysely\n .selectFrom('audit')\n .select(['auditId', 'occurredAt', 'type', 'actorUserId', 'tables', 'changedCols'])\n .orderBy('occurredAt', 'desc')\n .limit(input.limit)\n if (input.actorUserId) query = query.where('actorUserId', '=', input.actorUserId)\n const rows = await query.execute()\n\n // `tables` / `changed_cols` are stored as JSON text, so they arrive as strings.\n const parseList = (raw: string | null): string[] => (raw ? JSON.parse(raw) : [])\n return {\n events: rows.map((r) => ({\n auditId: r.auditId,\n occurredAt: r.occurredAt,\n type: r.type,\n actorUserId: r.actorUserId,\n tables: parseList(r.tables),\n changedCols: parseList(r.changedCols),\n })),\n }\n },\n})\n" }, { "name": "auth-session", "title": "Custom session fields (role / organizationId) — UserSession + mapSession", "when": "A function or permission needs a field on the logged-in session beyond `userId` — a role, the active org/team id, a locale. This is the #1 auth pitfall: a field added to the type is `undefined` at runtime until `mapSession` populates it. You must do BOTH steps below.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "STEP 1 — add the field to the EXISTING `UserSession` interface in\npackages/functions/src/application-types.d.ts. That file already declares\n`export interface UserSession extends CoreUserSession { userId: string }` —\njust add your field to it (the first commented block below is the shape):\n\n export interface UserSession extends CoreUserSession {\n userId: string\n role?: string // ← optional unless you added the admin() plugin\n organizationId?: string // ← the active org/team (organization() plugin)\n }\n\n⚠️ NEVER create a new `.d.ts` with `declare module '#pikku/*'`. `#pikku/*` is a\nSUBPATH IMPORT (package.json \"imports\"), not a package — an ambient\n`declare module '#pikku/*'` REPLACES it and erases every real export\n(pikkuFunc, pikkuBetterAuth, …). The whole app stops type-checking and\nbooting. Edit the UserSession interface in application-types.d.ts. Nothing else.\n\nSTEP 2 — populate the fields by registering your OWN global session middleware\nwith a `mapSession`. CREATE the file — it does not exist yet, so do not go looking\nfor it: `packages/functions/src/middleware/session.middleware.ts`, beside the\n`cors.middleware.ts` the starter already ships there. The code below is what goes\nin it.\n\n⚠️ READ THIS FIRST — it kills the three wrong turns everyone takes here (do NOT\nwaste turns re-deriving this; the mechanism is settled):\n • You are NOT double-registering. The generated bridge lives in\n packages/functions/src/scaffold/auth/auth-middleware.gen.ts. The MOMENT you add your\n own betterAuthStatelessSession below and run `pikku all` (codegen), the CLI\n DELETES that generated file entirely (pikkujs/pikku#754) — so EXACTLY ONE\n session middleware runs: yours. `ls` it after codegen and watch it vanish.\n • Do NOT edit auth-middleware.gen.ts. It is AUTO-GENERATED and about to be\n deleted — any edit is discarded. Your mapSession goes in the file you create\n in STEP 2, nowhere else.\n • Do NOT try to thread mapSession through pikkuBetterAuth in auth.ts. There is NO\n such option on the wrapper; mapSession exists ONLY on betterAuthStatelessSession.\n\n⚠️ CARRY OVER the authBearer(PIKKU_CONSOLE_TOKEN) block too. The generated file\nyou're replacing had it in the SAME addHTTPMiddleware array — and because #754\nremoves the WHOLE file, dropping it here silently breaks the Fabric console/agent\n(they read the app with that bearer token, so every console read starts 403'ing).\nKeep it verbatim as the 2nd entry below.\n\nThe starter uses Better Auth's stateless cookie session, so use\nbetterAuthStatelessSession (use betterAuthSession only if you turned cookieCache off).\n\nSCOPES (capability gates): if you gate any function with `scopes: [...]` (see the\n`wire-scope` scaffold), you MUST grant them in the SAME mapSession object below —\n`session.scopes` set by mapSession is authoritative, and a gated function 403s\nanyone who wasn't granted the scope (fail-closed). Derive from role; '*' grants all:\n scopes: (result.user as { role?: string }).role === 'admin' ? ['*'] : [],\nOMIT that line entirely if the app has no `scopes:`-gated functions.\n\nFIELD FROM A LINK TABLE (the tenant is NOT on the Better Auth user/session).\nWhen the scope lives in your OWN join table — NOT a Better Auth plugin column —\n`mapSession` is ASYNC and receives the singleton `services` as its 2nd arg, so\nquery there. DO NOT reach for databaseHooks, a second middleware, or the Better\nAuth MCP — this async-services signature is a pikku wrapper feature. (This is the\nLINK-TABLE case. If instead you added the Better Auth `organization()` PLUGIN,\nthe active org lives on the session as `activeOrganizationId` — read it as shown\nin the mapSession below, and do the sign-up org-bootstrap in databaseHooks the way\nBetter Auth's organization() plugin documents; that is the ONE place databaseHooks\nis right.) Still NEVER throw (runs every request). The second commented block below\nuses a generic `membership → tenantId` join; rename it to YOUR link table + scope\nfield.\n\nSTEP 3 — read it from the 3rd arg in functions/permissions; NEVER re-query the\nDB for a field that belongs on the session.\n\n⚠️ THE FIRST USER OF A BRAND-NEW APP HAS NO TENANT ROWS. The person who signs\nup (and the smoke/verify user) has zero orgs/workspaces/warehouses — so any\ncustom tenant field (organizationId, warehouseId, …) is `undefined` for them.\nThis is the #1 cause of a dashboard that 500s on every fresh login:\n const warehouseId = session.warehouseId! // ← undefined at runtime!\n .where('warehouseId', '=', warehouseId) // ← WHERE x = undefined → 500\nNEVER assert a tenant field with `!` and NEVER pass it into a query unguarded.\nA READ/list/dashboard function MUST return safe empty defaults when it is\nabsent (so a new user sees an empty-but-working app, not a 500) — the last\ncommented block below is that shape.\n\nSTEP 4 — BOOTSTRAP a default tenant so the app isn't empty forever, or the first\nwrite 500s. FOR THE organization() PLUGIN: put databaseHooks on the auth config that\ncreate + activate a default org on sign-up, as that plugin documents — that is the\nONE place databaseHooks is right. FOR A LINK-TABLE tenant: on the first\nauthenticated write (or in mapSession), if the user has no tenant row, create one\nand use it — don't make the user hit a dead end. Only a function that genuinely\ncannot proceed without a tenant may throw, and even then prefer creating the\ndefault over erroring. (`permissions` may still gate on a role:\n permissions: { isAdmin: (_s, _i, { session }) => session.role === 'admin' } )", "source": "packages/cli/examples/wires/auth-session.ts", "content": "//~ name: auth-session\n//~ title: Custom session fields (role / organizationId) — UserSession + mapSession\n//~ when: A function or permission needs a field on the logged-in session beyond `userId` — a role, the active org/team id, a locale. This is the #1 auth pitfall: a field added to the type is `undefined` at runtime until `mapSession` populates it. You must do BOTH steps below.\n//~ lang: ts\n//~ steps:\n//~ STEP 1 — add the field to the EXISTING `UserSession` interface in\n//~ packages/functions/src/application-types.d.ts. That file already declares\n//~ `export interface UserSession extends CoreUserSession { userId: string }` —\n//~ just add your field to it (the first commented block below is the shape):\n//~\n//~ export interface UserSession extends CoreUserSession {\n//~ userId: string\n//~ role?: string // ← optional unless you added the admin() plugin\n//~ organizationId?: string // ← the active org/team (organization() plugin)\n//~ }\n//~\n//~ ⚠️ NEVER create a new `.d.ts` with `declare module '#pikku/*'`. `#pikku/*` is a\n//~ SUBPATH IMPORT (package.json \"imports\"), not a package — an ambient\n//~ `declare module '#pikku/*'` REPLACES it and erases every real export\n//~ (pikkuFunc, pikkuBetterAuth, …). The whole app stops type-checking and\n//~ booting. Edit the UserSession interface in application-types.d.ts. Nothing else.\n//~\n//~ STEP 2 — populate the fields by registering your OWN global session middleware\n//~ with a `mapSession`. CREATE the file — it does not exist yet, so do not go looking\n//~ for it: `packages/functions/src/middleware/session.middleware.ts`, beside the\n//~ `cors.middleware.ts` the starter already ships there. The code below is what goes\n//~ in it.\n//~\n//~ ⚠️ READ THIS FIRST — it kills the three wrong turns everyone takes here (do NOT\n//~ waste turns re-deriving this; the mechanism is settled):\n//~ • You are NOT double-registering. The generated bridge lives in\n//~ packages/functions/src/scaffold/auth/auth-middleware.gen.ts. The MOMENT you add your\n//~ own betterAuthStatelessSession below and run `pikku all` (codegen), the CLI\n//~ DELETES that generated file entirely (pikkujs/pikku#754) — so EXACTLY ONE\n//~ session middleware runs: yours. `ls` it after codegen and watch it vanish.\n//~ • Do NOT edit auth-middleware.gen.ts. It is AUTO-GENERATED and about to be\n//~ deleted — any edit is discarded. Your mapSession goes in the file you create\n//~ in STEP 2, nowhere else.\n//~ • Do NOT try to thread mapSession through pikkuBetterAuth in auth.ts. There is NO\n//~ such option on the wrapper; mapSession exists ONLY on betterAuthStatelessSession.\n//~\n//~ ⚠️ CARRY OVER the authBearer(PIKKU_CONSOLE_TOKEN) block too. The generated file\n//~ you're replacing had it in the SAME addHTTPMiddleware array — and because #754\n//~ removes the WHOLE file, dropping it here silently breaks the Fabric console/agent\n//~ (they read the app with that bearer token, so every console read starts 403'ing).\n//~ Keep it verbatim as the 2nd entry below.\n//~\n//~ The starter uses Better Auth's stateless cookie session, so use\n//~ betterAuthStatelessSession (use betterAuthSession only if you turned cookieCache off).\n//~\n//~ SCOPES (capability gates): if you gate any function with `scopes: [...]` (see the\n//~ `wire-scope` scaffold), you MUST grant them in the SAME mapSession object below —\n//~ `session.scopes` set by mapSession is authoritative, and a gated function 403s\n//~ anyone who wasn't granted the scope (fail-closed). Derive from role; '*' grants all:\n//~ scopes: (result.user as { role?: string }).role === 'admin' ? ['*'] : [],\n//~ OMIT that line entirely if the app has no `scopes:`-gated functions.\n//~\n//~ FIELD FROM A LINK TABLE (the tenant is NOT on the Better Auth user/session).\n//~ When the scope lives in your OWN join table — NOT a Better Auth plugin column —\n//~ `mapSession` is ASYNC and receives the singleton `services` as its 2nd arg, so\n//~ query there. DO NOT reach for databaseHooks, a second middleware, or the Better\n//~ Auth MCP — this async-services signature is a pikku wrapper feature. (This is the\n//~ LINK-TABLE case. If instead you added the Better Auth `organization()` PLUGIN,\n//~ the active org lives on the session as `activeOrganizationId` — read it as shown\n//~ in the mapSession below, and do the sign-up org-bootstrap in databaseHooks the way\n//~ Better Auth's organization() plugin documents; that is the ONE place databaseHooks\n//~ is right.) Still NEVER throw (runs every request). The second commented block below\n//~ uses a generic `membership → tenantId` join; rename it to YOUR link table + scope\n//~ field.\n//~\n//~ STEP 3 — read it from the 3rd arg in functions/permissions; NEVER re-query the\n//~ DB for a field that belongs on the session.\n//~\n//~ ⚠️ THE FIRST USER OF A BRAND-NEW APP HAS NO TENANT ROWS. The person who signs\n//~ up (and the smoke/verify user) has zero orgs/workspaces/warehouses — so any\n//~ custom tenant field (organizationId, warehouseId, …) is `undefined` for them.\n//~ This is the #1 cause of a dashboard that 500s on every fresh login:\n//~ const warehouseId = session.warehouseId! // ← undefined at runtime!\n//~ .where('warehouseId', '=', warehouseId) // ← WHERE x = undefined → 500\n//~ NEVER assert a tenant field with `!` and NEVER pass it into a query unguarded.\n//~ A READ/list/dashboard function MUST return safe empty defaults when it is\n//~ absent (so a new user sees an empty-but-working app, not a 500) — the last\n//~ commented block below is that shape.\n//~\n//~ STEP 4 — BOOTSTRAP a default tenant so the app isn't empty forever, or the first\n//~ write 500s. FOR THE organization() PLUGIN: put databaseHooks on the auth config that\n//~ create + activate a default org on sign-up, as that plugin documents — that is the\n//~ ONE place databaseHooks is right. FOR A LINK-TABLE tenant: on the first\n//~ authenticated write (or in mapSession), if the user has no tenant row, create one\n//~ and use it — don't make the user hit a dead end. Only a function that genuinely\n//~ cannot proceed without a tenant may throw, and even then prefer creating the\n//~ default over erroring. (`permissions` may still gate on a role:\n//~ permissions: { isAdmin: (_s, _i, { session }) => session.role === 'admin' } )\n//\n// export interface UserSession extends CoreUserSession {\n// userId: string\n// role?: string // ← optional unless you added the admin() plugin\n// organizationId?: string // ← the active org/team (organization() plugin)\n// }\n//\n//\nimport { addHTTPMiddleware } from '#pikku/middleware'\nimport { betterAuthStatelessSession } from '@pikku/better-auth'\nimport { authBearer } from '@pikku/core/middleware'\n\naddHTTPMiddleware('*', [\n betterAuthStatelessSession({\n // ⚠️ mapSession runs on EVERY request — including sign-in/sign-up itself. If it\n // THROWS, every request 500s and NO ONE can sign in (the whole app looks dead).\n // So NEVER throw here and NEVER assume a field is present: read defensively and\n // default. A missing org/role means \"not set yet\", not \"crash the request\".\n // `result.user` is Better Auth's user row (plus plugin columns like `role`);\n // `result.session` carries the organization plugin's `activeOrganizationId`.\n mapSession: (result) => ({\n userId: result.user.id,\n role: (result.user as { role?: string }).role ?? undefined,\n organizationId:\n (result.session as { activeOrganizationId?: string }).activeOrganizationId ?? undefined,\n }),\n }),\n // Console bridge — carried over verbatim from the generated auth-middleware.gen.ts\n // that #754 removes when you register your own session above. Do NOT drop it, and do\n // NOT drop its `scopes`: the Fabric console/agent authenticate to the app with this\n // bearer token, and the console addon is gated on `pikku:console`. Without the grant\n // the token authenticates fine and then every console read 403s on a missing scope.\n authBearer({\n token: {\n secretId: 'PIKKU_CONSOLE_TOKEN',\n userSession: { userId: 'pikku-console-token', scopes: ['admin', 'pikku'] },\n },\n }),\n])\n\n// addHTTPMiddleware('*', [\n// betterAuthStatelessSession({\n// mapSession: async (result, { kysely }) => {\n// const link = await kysely\n// .selectFrom('membership')\n// .select('tenantId')\n// .where('userId', '=', result.user.id)\n// .executeTakeFirst() // may be undefined for a brand-new user\n// return { userId: result.user.id, tenantId: link?.tenantId }\n// },\n// }),\n// ])\n\n// func: async ({ kysely }, _input, { session }) => {\n// if (!session.warehouseId) return { totalSkus: 0, lowStockCount: 0 } // empty, no throw\n// // …scoped query only once the field is known present…\n// }\n" }, { "name": "channel", "title": "Realtime channel (WebSocket) — connect/disconnect + message actions", "when": "The app needs LIVE two-way updates — presence, a chat room, a live board, push-on-change. For one-way server→client streaming/progress use {name: sse} instead.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/channel.ts", "content": "//~ name: channel\n//~ title: Realtime channel (WebSocket) — connect/disconnect + message actions\n//~ when: The app needs LIVE two-way updates — presence, a chat room, a live board, push-on-change. For one-way server→client streaming/progress use {name: sse} instead.\n//~ entity: todo\n//~ The three lifecycle funcs share one file on purpose — they are one channel's\n//~ behaviour, not three independent RPCs, and pikku discovers them by export. The\n//~ wireChannel call is what must live apart.\n\n// ===== FILE: packages/functions/src/functions/todo-channel.function.ts =====\nimport { z } from 'zod'\nimport { pikkuChannelFunc, pikkuChannelConnectionFunc, pikkuChannelDisconnectionFunc } from '#pikku/channel'\n\n//~ Connection lifecycle: send an initial frame on connect. The 3rd arg carries\n//~ `channel` (channel.channelId, channel.send(...)). Generic = the send() shape.\nexport const onTodoConnect = pikkuChannelConnectionFunc<{ connected: true }>(\n async ({ logger }, _input, { channel }) => {\n logger.info(`connected: ${channel.channelId}`)\n channel.send({ connected: true })\n },\n)\n\n//~ EventHub auto-unsubscribes on disconnect — just log/cleanup here.\nexport const onTodoDisconnect = pikkuChannelDisconnectionFunc(\n async ({ logger }, _input, { channel }) => {\n logger.info(`disconnected: ${channel.channelId}`)\n },\n)\n\n//~ A message action = a pikkuChannelFunc with input/output zod. `setSession`\n//~ (3rd arg) authenticates the socket; after it, later actions see the session.\n//~ input/output are ALWAYS named module-level consts — NEVER inline at the\n//~ input:/output: site (pikku rejects inline expressions with PKU489).\nexport const SubscribeTodoInput = z.object({ topic: z.string() })\nexport const SubscribeTodoOutput = z.object({ subscribed: z.boolean(), topic: z.string() })\n\nexport const subscribeTodo = pikkuChannelFunc({\n input: SubscribeTodoInput,\n output: SubscribeTodoOutput,\n func: async ({ eventHub, logger }, { topic }, { channel }) => {\n await eventHub?.subscribe(topic, channel.channelId)\n logger.info(`${channel.channelId} subscribed to ${topic}`)\n return { subscribed: Boolean(eventHub), topic }\n },\n})\n\n// ===== FILE: packages/functions/src/wires/channel/todos-live.channel.ts =====\nimport { wireChannel } from '#pikku/channel'\nimport {\n onTodoConnect,\n onTodoDisconnect,\n subscribeTodo,\n} from '../../functions/todo-channel.function.js'\n\n//~ `onMessageWiring.action` maps an action name (the client sends\n//~ { action: 'subscribe', ... }) to a func. Reuse existing RPC funcs here too\n//~ (e.g. list/create) — the socket calls them live. Broadcast to subscribers from\n//~ any function: `await eventHub.publish(topic, data)`.\nwireChannel({\n name: 'todos-live',\n route: '/',\n onConnect: onTodoConnect,\n onDisconnect: onTodoDisconnect,\n onMessageWiring: {\n action: {\n subscribe: { func: subscribeTodo },\n },\n },\n tags: ['realtime'],\n})\n" }, { "name": "cli", "title": "Ops CLI (server-side, over a channel) — run app functions from the terminal", "when": "A back-office / ops command-line tool that reuses the app's OWN pikku functions (list rows, force a state change, mark paid, reconcile, export). It is SERVER-SIDE: the CLI client connects to the running server over a channel and the functions execute there — nothing is bundled locally and it must not affect the web deploy. Defining the commands below is only the first of three steps; the entrypoint registration and the login flow are both listed under NEXT STEPS in this reply and the CLI is unusable without them.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "NEXT STEPS — the commands above are wired, but nothing can RUN them yet. Do both.\n\nSTEP 2 — write the /device approval page. Better Auth's device flow opens it with\nthe code in the URL; the signed-in user reads the code back and clicks Approve, which\nunblocks the waiting terminal. Without it `login` has nowhere to confirm and hangs.\n\nSTEP 2 — register the SERVER-SIDE entrypoint in pikku.config.json.\nAdd this under the top-level \"cli\" key (create it if absent), renaming \"ops\" to your\nown `program`. The key is `entrypoints`; pikku reads NOTHING else — a `cli` block of\nany other shape (a `programs` array, say) is silently ignored, no client is generated\nand every command fails. The `channel` variant makes the CLI connect to the running\nserver and execute there; its generated wire goes under src/scaffold/ (a generated\ndir). All three paths are GENERATED (*.gen.ts) — never hand-write them; `pikku all`\nfills them in.\n\n \"cli\": {\n \"entrypoints\": {\n \"ops\": [\n \"client/cli-local.gen.ts\",\n {\n \"type\": \"channel\",\n \"wirePath\": \"packages/functions/src/scaffold/cli-channel.gen.ts\",\n \"path\": \"client/cli-client.gen.ts\"\n }\n ]\n }\n }\n\nSTEP 3 — AUTH: make the CLI log in as a real user via Better Auth (built in).\nA command that reads/writes user data must run AS someone — the generated CLI client\nsends the caller's session on the WebSocket, so the app just needs the login endpoints\nturned on. This is the CLI's own `login` subcommand (the OAuth 2.0 device flow) and it\nis NOT on by default. The /device approval page was just written for you; do the rest:\n 1. Add the two Better Auth plugins to the auth.ts `plugins: [...]`, then run\n `pikku db generate` and apply the migration it writes:\n deviceAuthorization() # /auth/device/* endpoints (+ device_code table)\n bearer() # accept Authorization: Bearer <token> (no table)\n device-authorization is what `login` drives; bearer is what lets the token\n authenticate the WebSocket. Enable BOTH — one without the other leaves login\n half-wired and the failure looks like a hung terminal.\n 2. Mark each command that needs a user `auth: true` in its pikkuCLICommand (a truly\n public command — a status ping — can stay auth:false). The channel resolves the\n bearer to a session the same way HTTP does, so `permissions` and `session` work in\n the command's func exactly as in the web app.\n\nRUN IT (from the app repo, after the above ship). The CLI binary is named after\n`program` above — NOT `pikku`; login/whoami/logout are ITS subcommands:\n <program> login --url https://<your-app-url> # opens the browser, confirm the code\n <program> <command> [--flags] # e.g. `ops lots --status live`\nThe session is saved to ~/.pikku/session.json and every command reuses it until it\nexpires (`<program> whoami` to check, `<program> logout` to clear). For unattended /\nmachine use, skip login and set PIKKU_API_KEY (sent as x-api-key) instead.\n\nTELL THE USER the binary name and the `login` line. A CLI nobody knows how to invoke\nhas not been delivered.", "source": "packages/cli/examples/wires/cli.ts", "content": "//~ name: cli\n//~ title: Ops CLI (server-side, over a channel) — run app functions from the terminal\n//~ when: A back-office / ops command-line tool that reuses the app's OWN pikku functions (list rows, force a state change, mark paid, reconcile, export). It is SERVER-SIDE: the CLI client connects to the running server over a channel and the functions execute there — nothing is bundled locally and it must not affect the web deploy. Defining the commands below is only the first of three steps; the entrypoint registration and the login flow are both listed under NEXT STEPS in this reply and the CLI is unusable without them.\n//~ lang: ts\n//~ steps:\n//~ NEXT STEPS — the commands above are wired, but nothing can RUN them yet. Do both.\n//~\n//~ STEP 2 — write the /device approval page. Better Auth's device flow opens it with\n//~ the code in the URL; the signed-in user reads the code back and clicks Approve, which\n//~ unblocks the waiting terminal. Without it `login` has nowhere to confirm and hangs.\n//~\n//~ STEP 2 — register the SERVER-SIDE entrypoint in pikku.config.json.\n//~ Add this under the top-level \"cli\" key (create it if absent), renaming \"ops\" to your\n//~ own `program`. The key is `entrypoints`; pikku reads NOTHING else — a `cli` block of\n//~ any other shape (a `programs` array, say) is silently ignored, no client is generated\n//~ and every command fails. The `channel` variant makes the CLI connect to the running\n//~ server and execute there; its generated wire goes under src/scaffold/ (a generated\n//~ dir). All three paths are GENERATED (*.gen.ts) — never hand-write them; `pikku all`\n//~ fills them in.\n//~\n//~ \"cli\": {\n//~ \"entrypoints\": {\n//~ \"ops\": [\n//~ \"client/cli-local.gen.ts\",\n//~ {\n//~ \"type\": \"channel\",\n//~ \"wirePath\": \"packages/functions/src/scaffold/cli-channel.gen.ts\",\n//~ \"path\": \"client/cli-client.gen.ts\"\n//~ }\n//~ ]\n//~ }\n//~ }\n//~\n//~ STEP 3 — AUTH: make the CLI log in as a real user via Better Auth (built in).\n//~ A command that reads/writes user data must run AS someone — the generated CLI client\n//~ sends the caller's session on the WebSocket, so the app just needs the login endpoints\n//~ turned on. This is the CLI's own `login` subcommand (the OAuth 2.0 device flow) and it\n//~ is NOT on by default. The /device approval page was just written for you; do the rest:\n//~ 1. Add the two Better Auth plugins to the auth.ts `plugins: [...]`, then run\n//~ `pikku db generate` and apply the migration it writes:\n//~ deviceAuthorization() # /auth/device/* endpoints (+ device_code table)\n//~ bearer() # accept Authorization: Bearer <token> (no table)\n//~ device-authorization is what `login` drives; bearer is what lets the token\n//~ authenticate the WebSocket. Enable BOTH — one without the other leaves login\n//~ half-wired and the failure looks like a hung terminal.\n//~ 2. Mark each command that needs a user `auth: true` in its pikkuCLICommand (a truly\n//~ public command — a status ping — can stay auth:false). The channel resolves the\n//~ bearer to a session the same way HTTP does, so `permissions` and `session` work in\n//~ the command's func exactly as in the web app.\n//~\n//~ RUN IT (from the app repo, after the above ship). The CLI binary is named after\n//~ `program` above — NOT `pikku`; login/whoami/logout are ITS subcommands:\n//~ <program> login --url https://<your-app-url> # opens the browser, confirm the code\n//~ <program> <command> [--flags] # e.g. `ops lots --status live`\n//~ The session is saved to ~/.pikku/session.json and every command reuses it until it\n//~ expires (`<program> whoami` to check, `<program> logout` to clear). For unattended /\n//~ machine use, skip login and set PIKKU_API_KEY (sent as x-api-key) instead.\n//~\n//~ TELL THE USER the binary name and the `login` line. A CLI nobody knows how to invoke\n//~ has not been delivered.\nimport { wireCLI, pikkuCLICommand, pikkuCLIRender } from '#pikku/cli'\n//~ Import the SAME functions the app already uses — a CLI command is just another\n//~ transport in front of a pikkuSessionlessFunc. NEVER write CLI-only business\n//~ logic here; if a command needs new behaviour, add a *.function.ts and import it.\nimport { listLots } from '../../functions/list-lots.function.js'\nimport { markInvoicePaid } from '../../functions/mark-invoice-paid.function.js'\n\n//~ A renderer turns a command's OUTPUT into stdout text. The render generic MUST\n//~ match that function's output-schema type — give each command a renderer for its\n//~ own shape (reuse one only when the shapes match).\nconst renderLots = pikkuCLIRender<Array<{ id: string; title: string; status: string }>>(\n (_services, lots) => {\n for (const l of lots) console.log(`${l.id}\\t${l.status}\\t${l.title}`)\n console.log(`\\n${lots.length} lot(s)`)\n },\n)\n\nconst renderInvoice = pikkuCLIRender<{ id: string; paid: boolean }>((_services, inv) => {\n console.log(`invoice ${inv.id} -> ${inv.paid ? 'paid' : 'unpaid'}`)\n})\n\n//~ program = the CLI's name; each command wraps a func + declares its flags. Flags\n//~ map to the func INPUT schema (camelCase); `short` is optional.\nwireCLI({\n program: 'ops',\n commands: {\n lots: pikkuCLICommand({\n func: listLots,\n description: 'List lots, optionally by status',\n render: renderLots,\n options: {\n status: { description: 'draft | live | hammered | ...', short: 's' },\n },\n }),\n 'mark-paid': pikkuCLICommand({\n func: markInvoicePaid,\n description: 'Mark an invoice paid by id',\n render: renderInvoice,\n options: {\n id: { description: 'invoice id' },\n },\n }),\n },\n render: renderLots,\n})\n" }, { "name": "feature-flags", "title": "Feature flags — one `getFeatures` RPC the frontend uses to show/hide screens", "when": "The app has ROLES and the UI must differ between them — hide a nav item, skip a page, drop a button. Pull this the moment you pull `wire-scope`: scopes gate the SERVER, this is the matching CLIENT surface, and without it every role sees the same screen. Needs `wire-scope` (declares the scopes) + `auth-session` (grants them in mapSession).", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "⚠️ A FEATURE HIDES UI. IT NEVER PROTECTS ANYTHING.\nHiding a button behind a feature while leaving `scopes:` off the function is the\nONE failure this scaffold can cause: the app LOOKS gated, every screenshot and\nscenario passes, and the RPC still answers anyone who calls it directly. The\nguarantee is ALWAYS the function's own `scopes: [...]` (see `wire-scope` STEP 3).\nRule: every feature you hide a control behind must ALSO have the scope on the\nfunction that control calls. Hide AND gate — never one or the other.\n\nWHY NOT JUST SEND `session.scopes` TO THE CLIENT? Because then every screen learns\nthe capability vocabulary, a scope rename becomes a frontend refactor, and each\ncall site has to re-implement parent/wildcard resolution (`admin` covers\n`admin:invoices:void`) — which someone will get wrong in the PERMISSIVE direction.\nA feature is a UI-facing NAME; the scopes behind it stay a server concern.\n\nSTEP 1 — MAP each feature to the scopes that satisfy it, in\npackages/functions/src/lib/features.ts. `ScopeId` is generated from your\n`defineScope` tree (run `pikku all` after declaring it), so a typo'd scope is a\nCOMPILE error rather than a silently-always-false feature.\nSemantics are OR: holding ANY listed scope turns the feature on — a flag usually\nreveals one entry point that several roles can reach.\nAn EMPTY array means visible to EVERYONE — `hasScopes([], …)` is satisfied by\nanything, which is the correct reading of \"this feature requires no capability\".\nUse it for a feature every signed-in user gets. It does NOT mean \"nobody yet\";\nfor that, omit the feature entirely.\n⚠️ The `ScopeId` import below is the GENERATED scopes file, NOT `#pikku`. Before you\ndeclare a `defineScope` tree, `ScopeId` is `never` — so every entry in FEATURES is a\ntype error until STEP 1 of `wire-scope` exists. That is the right order: declare\nscopes, `pikku all`, then map features onto them.\n\nSTEP 2 — EXPOSE them as one RPC, in\npackages/functions/src/functions/get-features.function.ts. Shaped exactly like the\ntemplate's `getSession` (readonly, auth'd, no input) so the client fetches it once\non load beside the session.\n`hasScopes` is pikku's own non-throwing checker — it understands parent grants and\n`*`, which is precisely the logic the frontend must never re-implement.\nList every feature explicitly in the output schema — do NOT build it from\nObject.keys(). The generated client type comes from THAT object, so spelling the\nkeys out is what makes `features.calList` a compile error on the frontend instead of\n`undefined` (which reads as false, hides the feature forever, and leaves every test\ngreen). Adding a feature therefore touches three places: FEATURES, the output\nschema, and the returned object. The compiler enforces the last two against each\nother.\n\nSTEP 3 — CONSUME it on the client. Fetch once and gate nav + routes off it. The\ntype comes from the generated client — NEVER hand-write a Features interface. The\nfirst commented block below is the hook and the hidden nav item.\n⚠️ WHILE `features` IS UNDEFINED, RENDER NOTHING — not the feature. `features?.x`\nis undefined on the first paint, so `{!features?.x && <Locked/>}` flashes a locked\nstate at the very users who DO have access. Gate on the positive, as shown.\nAND GUARD THE ROUTE, not just the link. A hidden nav item is still reachable by\ntyping the URL; without a route guard the page renders and its RPCs 403, which\nlooks broken rather than absent — the last commented line below is that guard.\n\nSTEP 4 — PROVE IT WITH A SCENARIO. Each persona should see its OWN feature set;\nthat is the regression test for this whole file, and it is what catches a feature\nthat silently went all-false because a scope was renamed. Drive it per-actor with\nthe `scenario-permissions` scaffold: assert the counter sees `takeInBike` and the\nmechanic does not, then assert the DENIED path (`scenario.expectError`) on the\nfunction itself — which is what proves the `scopes:` gate is really there and you\ndid not merely hide the button.", "source": "packages/cli/examples/wires/feature-flags.ts", "content": "//~ name: feature-flags\n//~ title: Feature flags — one `getFeatures` RPC the frontend uses to show/hide screens\n//~ when: The app has ROLES and the UI must differ between them — hide a nav item, skip a page, drop a button. Pull this the moment you pull `wire-scope`: scopes gate the SERVER, this is the matching CLIENT surface, and without it every role sees the same screen. Needs `wire-scope` (declares the scopes) + `auth-session` (grants them in mapSession).\n//~ lang: ts\n//~ steps:\n//~ ⚠️ A FEATURE HIDES UI. IT NEVER PROTECTS ANYTHING.\n//~ Hiding a button behind a feature while leaving `scopes:` off the function is the\n//~ ONE failure this scaffold can cause: the app LOOKS gated, every screenshot and\n//~ scenario passes, and the RPC still answers anyone who calls it directly. The\n//~ guarantee is ALWAYS the function's own `scopes: [...]` (see `wire-scope` STEP 3).\n//~ Rule: every feature you hide a control behind must ALSO have the scope on the\n//~ function that control calls. Hide AND gate — never one or the other.\n//~\n//~ WHY NOT JUST SEND `session.scopes` TO THE CLIENT? Because then every screen learns\n//~ the capability vocabulary, a scope rename becomes a frontend refactor, and each\n//~ call site has to re-implement parent/wildcard resolution (`admin` covers\n//~ `admin:invoices:void`) — which someone will get wrong in the PERMISSIVE direction.\n//~ A feature is a UI-facing NAME; the scopes behind it stay a server concern.\n//~\n//~ STEP 1 — MAP each feature to the scopes that satisfy it, in\n//~ packages/functions/src/lib/features.ts. `ScopeId` is generated from your\n//~ `defineScope` tree (run `pikku all` after declaring it), so a typo'd scope is a\n//~ COMPILE error rather than a silently-always-false feature.\n//~ Semantics are OR: holding ANY listed scope turns the feature on — a flag usually\n//~ reveals one entry point that several roles can reach.\n//~ An EMPTY array means visible to EVERYONE — `hasScopes([], …)` is satisfied by\n//~ anything, which is the correct reading of \"this feature requires no capability\".\n//~ Use it for a feature every signed-in user gets. It does NOT mean \"nobody yet\";\n//~ for that, omit the feature entirely.\n//~ ⚠️ The `ScopeId` import below is the GENERATED scopes file, NOT `#pikku`. Before you\n//~ declare a `defineScope` tree, `ScopeId` is `never` — so every entry in FEATURES is a\n//~ type error until STEP 1 of `wire-scope` exists. That is the right order: declare\n//~ scopes, `pikku all`, then map features onto them.\n//~\n//~ STEP 2 — EXPOSE them as one RPC, in\n//~ packages/functions/src/functions/get-features.function.ts. Shaped exactly like the\n//~ template's `getSession` (readonly, auth'd, no input) so the client fetches it once\n//~ on load beside the session.\n//~ `hasScopes` is pikku's own non-throwing checker — it understands parent grants and\n//~ `*`, which is precisely the logic the frontend must never re-implement.\n//~ List every feature explicitly in the output schema — do NOT build it from\n//~ Object.keys(). The generated client type comes from THAT object, so spelling the\n//~ keys out is what makes `features.calList` a compile error on the frontend instead of\n//~ `undefined` (which reads as false, hides the feature forever, and leaves every test\n//~ green). Adding a feature therefore touches three places: FEATURES, the output\n//~ schema, and the returned object. The compiler enforces the last two against each\n//~ other.\n//~\n//~ STEP 3 — CONSUME it on the client. Fetch once and gate nav + routes off it. The\n//~ type comes from the generated client — NEVER hand-write a Features interface. The\n//~ first commented block below is the hook and the hidden nav item.\n//~ ⚠️ WHILE `features` IS UNDEFINED, RENDER NOTHING — not the feature. `features?.x`\n//~ is undefined on the first paint, so `{!features?.x && <Locked/>}` flashes a locked\n//~ state at the very users who DO have access. Gate on the positive, as shown.\n//~ AND GUARD THE ROUTE, not just the link. A hidden nav item is still reachable by\n//~ typing the URL; without a route guard the page renders and its RPCs 403, which\n//~ looks broken rather than absent — the last commented line below is that guard.\n//~\n//~ STEP 4 — PROVE IT WITH A SCENARIO. Each persona should see its OWN feature set;\n//~ that is the regression test for this whole file, and it is what catches a feature\n//~ that silently went all-false because a scope was renamed. Drive it per-actor with\n//~ the `scenario-permissions` scaffold: assert the counter sees `takeInBike` and the\n//~ mechanic does not, then assert the DENIED path (`scenario.expectError`) on the\n//~ function itself — which is what proves the `scopes:` gate is really there and you\n//~ did not merely hide the button.\nimport type { ScopeId } from '#pikku/scopes/pikku-scopes.gen.js'\n\nexport const FEATURES = {\n takeInBike: ['bikes:intake'],\n workStand: ['bikes:repair'],\n callList: ['bikes:intake', 'shop:admin'],\n} as const satisfies Record<string, readonly ScopeId[]>\n\nexport type FeatureId = keyof typeof FEATURES\n\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\nimport { hasScopes } from '@pikku/core/scope'\n\nexport const GetFeaturesOutput = z.object({\n takeInBike: z.boolean(),\n workStand: z.boolean(),\n callList: z.boolean(),\n})\n\nexport const getFeatures = pikkuFunc({\n expose: true,\n readonly: true,\n auth: true,\n description: 'Which features the signed-in user may see. UI only — never a gate.',\n input: z.object({}),\n output: GetFeaturesOutput,\n func: async (_services, _input, { session }) => {\n const can = (feature: FeatureId) =>\n FEATURES[feature].some((scope) => hasScopes([scope], session?.scopes))\n\n return {\n takeInBike: can('takeInBike'),\n workStand: can('workStand'),\n callList: can('callList'),\n }\n },\n})\n\n//\n// // apps/app/src/lib/useFeatures.ts\n// import { useQuery } from '@tanstack/react-query'\n// import type { GetFeaturesOutput } from '@<app>/sdk/pikku/rpc-map.gen'\n//\n// export const useFeatures = () => {\n// const { data } = useQuery({\n// queryKey: ['features'],\n// queryFn: () => pikku.invoke('getFeatures', {}),\n// staleTime: Infinity, // refetches on reload / re-login, which is enough\n// })\n// return data\n// }\n//\n// // hiding a nav item\n// const features = useFeatures()\n// {features?.callList && <NavLink to=\"/call-list\" label={m.nav__call_list()} />}\n//\n//\n//\n// if (features && !features.workStand) return <Navigate to=\"/\" replace />\n//\n" }, { "name": "file-upload", "title": "File upload + view (public & private) via the `content` service", "when": "The app lets users upload/attach files — avatars, photos, documents, images. Two backend RPCs: presign an upload URL (client PUTs the bytes straight to storage), then hand back a viewable URL. Persist the returned fileKey on your row (e.g. todo.attachmentKey). The uploader UI is a SEPARATE frontend concern — see the file-upload-ui scaffold; style it however the app needs (dropzone, button, avatar picker…).", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/file-upload.ts", "content": "//~ name: file-upload\n//~ title: File upload + view (public & private) via the `content` service\n//~ when: The app lets users upload/attach files — avatars, photos, documents, images. Two backend RPCs: presign an upload URL (client PUTs the bytes straight to storage), then hand back a viewable URL. Persist the returned fileKey on your row (e.g. todo.attachmentKey). The uploader UI is a SEPARATE frontend concern — see the file-upload-ui scaffold; style it however the app needs (dropzone, button, avatar picker…).\n\n// ===== FILE: packages/functions/src/lib/file-keys.ts =====\n//~ One bucket groups related files. `content` is the injected pikku ContentService —\n//~ LocalContent in the sandbox (files on disk, served at /upload + /content), an\n//~ R2/S3-backed one in a deployed stage. It is ALWAYS injected: never guard it, and\n//~ never report it as unconfigured just because services.ts does not name it.\nexport const BUCKET = 'uploads'\n\n//~ keyGeneration — build a safe, namespaced storage key. PRIVATE files live under the\n//~ owner's userId so one user can never guess/read another's key; PUBLIC files live\n//~ under `public/`. The uuid stops collisions + name-guessing. NEVER use the raw\n//~ client fileName as the key — keep it only as a sanitized suffix.\nexport function buildFileKey(visibility: 'public' | 'private', userId: string, fileName: string) {\n const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)\n const prefix = visibility === 'public' ? 'public' : userId\n //~ Global crypto.randomUUID — works in the sandbox (bun/node) AND a deployed CF\n //~ Worker; never `import 'node:crypto'` (not resolvable on Workers).\n return `${prefix}/${crypto.randomUUID()}-${safeName}`\n}\n\n// ===== FILE: packages/functions/src/functions/request-file-upload.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\nimport { BUCKET, buildFileKey } from '../lib/file-keys.js'\n\nexport const RequestFileUploadInput = z.object({\n fileName: z.string().min(1),\n contentType: z.string().min(1),\n visibility: z.enum(['public', 'private']).default('private'),\n})\n\nexport const RequestFileUploadOutput = z.object({\n //~ The client PUTs the raw file bytes to this presigned URL (use uploadMethod).\n uploadUrl: z.string(),\n //~ Persist THIS on your row — it's how you read the file back. It's bucket-less on\n //~ purpose (getFileViewUrl re-adds the bucket), so never store `assetKey` instead.\n fileKey: z.string(),\n uploadMethod: z.enum(['PUT', 'POST']).optional(),\n uploadHeaders: z.record(z.string(), z.string()).optional(),\n})\n\nexport const requestFileUpload = pikkuFunc({\n expose: true,\n auth: true,\n description: 'Presign a URL to upload a file, scoped public or private to the user.',\n input: RequestFileUploadInput,\n output: RequestFileUploadOutput,\n func: async ({ content }, input, { session }) => {\n const fileKey = buildFileKey(input.visibility, session!.userId, input.fileName)\n //~ getUploadURL's uploadUrl already points at /upload/<bucket>/<fileKey>.\n const { uploadUrl, uploadMethod, uploadHeaders } = await content.getUploadURL({\n bucket: BUCKET,\n fileKey,\n contentType: input.contentType,\n visibility: input.visibility,\n })\n return { uploadUrl, fileKey, uploadMethod, uploadHeaders }\n },\n})\n\n// ===== FILE: packages/functions/src/functions/get-file-view-url.function.ts =====\nimport { z } from 'zod'\nimport { pikkuPermission } from '#pikku/auth'\nimport { pikkuFunc } from '#pikku/function'\nimport { BUCKET } from '../lib/file-keys.js'\n\n//~ Ownership lives in `permissions`, NOT the func body. Public files are viewable by\n//~ anyone; a private file is only the caller's if its key is under their userId.\nexport const ownsFile = pikkuPermission<{ fileKey: string; visibility: 'public' | 'private' }>(\n async (_services, { fileKey, visibility }, { session }) => {\n if (visibility === 'public') return true\n return !!session?.userId && fileKey.startsWith(`${session.userId}/`)\n },\n)\n\nexport const GetFileViewUrlInput = z.object({\n fileKey: z.string().min(1),\n visibility: z.enum(['public', 'private']).default('private'),\n})\nexport const GetFileViewUrlOutput = z.object({\n //~ A ready-to-use URL (put it straight in <img src> / a download link).\n url: z.string(),\n expiresAt: z.string(),\n})\n\nexport const getFileViewUrl = pikkuFunc({\n expose: true,\n readonly: true, //~ pure read — mints a URL, never writes\n auth: true,\n permissions: { ownsFile },\n description: 'Return a viewable URL for a file — long-lived for public, short-lived for private.',\n input: GetFileViewUrlInput,\n output: GetFileViewUrlOutput,\n func: async ({ content }, input) => {\n //~ signContentKey mints a signed, time-limited URL at the /content prefix. The\n //~ sandbox's LocalContent requires a signed URL even for public reads, so BOTH\n //~ paths sign — the difference is the window: public gets a far-future expiry\n //~ (effectively a stable shareable link), private a short one. In a deployed R2\n //~ stage a truly-public file can instead be served from a public bucket, but the\n //~ signed-URL path works everywhere, so prefer it unless you need hot-linking.\n const ttlMs = input.visibility === 'public' ? 3650 * 24 * 3_600_000 : 3_600_000\n const dateLessThan = new Date(Date.now() + ttlMs)\n const url = await content.signContentKey({\n bucket: BUCKET,\n contentKey: input.fileKey,\n dateLessThan,\n })\n return { url, expiresAt: dateLessThan.toISOString() }\n },\n})\n" }, { "name": "gateway-slack", "title": "Slack gateway (Events API webhook) — bot messages linked to app users", "when": "The brief wants the app reachable from Slack — a workspace bot users DM or @mention, notifications with replies, a support channel. Install first: `bun add @pikku/gateway-slack @slack/web-api`.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "PREREQS:\n1. `bun add @pikku/gateway-slack @slack/web-api`.\n2. Same gateway_identity migration as the whatsapp scaffold (shared table,\n different channel value) — see {name: gateway-whatsapp} for the SQL.\n3. In the Slack app: set `<your public origin>` + the route below as the Event\n Subscriptions request URL, subscribe to the `message.im` and `app_mention`\n bot events, and install. Slack's url_verification challenge is answered\n automatically once SLACK_CREDENTIALS is set.\n Do NOT enumerate secret fields in chat — the defineSecret below surfaces\n them as blocked-until-set in the Secrets dashboard automatically.", "source": "packages/cli/examples/wires/gateway-slack.ts", "content": "//~ name: gateway-slack\n//~ title: Slack gateway (Events API webhook) — bot messages linked to app users\n//~ when: The brief wants the app reachable from Slack — a workspace bot users DM or @mention, notifications with replies, a support channel. Install first: `bun add @pikku/gateway-slack @slack/web-api`.\n//~ steps:\n//~ PREREQS:\n//~ 1. `bun add @pikku/gateway-slack @slack/web-api`.\n//~ 2. Same gateway_identity migration as the whatsapp scaffold (shared table,\n//~ different channel value) — see {name: gateway-whatsapp} for the SQL.\n//~ 3. In the Slack app: set `<your public origin>` + the route below as the Event\n//~ Subscriptions request URL, subscribe to the `message.im` and `app_mention`\n//~ bot events, and install. Slack's url_verification challenge is answered\n//~ automatically once SLACK_CREDENTIALS is set.\n//~ Do NOT enumerate secret fields in chat — the defineSecret below surfaces\n//~ them as blocked-until-set in the Secrets dashboard automatically.\nimport { z } from 'zod'\nimport type { GatewayInboundMessage } from '@pikku/core/gateway'\nimport { wireGateway } from '@pikku/core/gateway'\nimport { defineSecret } from '#pikku/secrets'\nimport { SlackGatewayAdapter, SlackGatewayHelper } from '@pikku/gateway-slack'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ SECRET — in real code the zod schema lives in lib/slack-secret-schema.ts\n//~ with NO wire* calls in that file (mixing them is a PKU490 codegen error);\n//~ the defineSecret + wireGateway go together in wires/gateway/slack.wiring.ts.\n//~ accessToken = the bot token (xoxb-…), signingSecret = the app's signing\n//~ secret; the adapter REJECTS unsigned/forged webhooks with it.\nexport const slackSecretsSchema = z.object({\n accessToken: z.string().describe('Slack bot token (xoxb-…)'),\n signingSecret: z\n .string()\n .describe('Slack app signing secret — verifies inbound webhook signatures'),\n})\n\ndefineSecret({\n name: 'slack',\n displayName: 'Slack app',\n description: 'Slack bot credentials for the messaging gateway',\n secretId: 'SLACK_CREDENTIALS',\n schema: slackSecretsSchema,\n})\n\n//~ The adapter instance is kept module-level because replies need it:\n//~ SlackGatewayAdapter.send() is a NO-OP (no channel context), so returning\n//~ { text } from the handler does NOTHING on Slack — always reply through\n//~ SlackGatewayHelper, which binds team/channel/thread from the message.\nlet slackAdapter: SlackGatewayAdapter\n\n//~ HANDLER — senderId is the Slack user ID (e.g. U0123…). The runner already\n//~ filtered bot echoes/edits; only real user messages and @mentions arrive here.\nexport const onSlackMessage = pikkuSessionlessFunc({\n expose: false,\n description: 'Handle an inbound Slack message',\n func: async ({ kysely, logger }, message: GatewayInboundMessage) => {\n const senderId = message.senderId\n const slack = new SlackGatewayHelper(message, slackAdapter)\n\n //~ LINK SENDER → APP USER — same two policies as the whatsapp scaffold\n //~ (the brief decides; KEEP ONE):\n const identity = await kysely\n .selectFrom('gatewayIdentity')\n .select(['userId'])\n .where('channel', '=', 'slack')\n .where('externalId', '=', senderId)\n .executeTakeFirst()\n\n let userId = identity?.userId\n\n //~ POLICY A — OPEN (message creates the user):\n if (!userId) {\n const user = await kysely\n .insertInto('user')\n .values({\n id: crypto.randomUUID(),\n name: senderId,\n email: `slack-${senderId}@gateway.invalid`,\n emailVerified: false,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n .returning(['id'])\n .executeTakeFirstOrThrow()\n await kysely\n .insertInto('gatewayIdentity')\n .values({ channel: 'slack', externalId: senderId, userId: user.id })\n .execute()\n userId = user.id\n logger.info(`slack gateway: created user ${userId} for ${senderId}`)\n }\n\n //~ POLICY B — CLOSED (user must already exist):\n // if (!userId) {\n // await slack.sendText('This Slack account is not linked yet — connect it in the app first.')\n // return\n // }\n\n //~ DOMAIN LOGIC — act on message.text for userId, then reply IN-THREAD via\n //~ the helper (never `return { text }` — see the no-op note above):\n logger.info(`slack gateway: ${userId} said ${message.text}`)\n await slack.sendText(`Got it, <@${senderId}>!`)\n },\n})\n\n//~ WIRING — lives in wires/gateway/slack.wiring.ts together with the\n//~ defineSecret above (importing the schema from lib/ — see PKU490 note).\n//~ The adapter is a FACTORY resolved lazily at the first webhook: credentials\n//~ only exist after boot. Signature verification is enforced by the adapter on\n//~ EVERY request (invalid/missing/stale → 401) before your handler runs.\nwireGateway({\n name: 'slack',\n type: 'webhook',\n route: '/gateways/slack',\n adapter: async ({ secrets }) => {\n const creds = slackSecretsSchema.parse((await secrets.getSecret('SLACK_CREDENTIALS')).reveal())\n slackAdapter = new SlackGatewayAdapter({\n signingSecret: creds.signingSecret,\n //~ single-workspace app: one bot token. Multi-workspace → look the token\n //~ up per teamId from your own table here instead.\n tokenResolver: async () => creds.accessToken,\n })\n return slackAdapter\n },\n func: onSlackMessage,\n auth: false,\n})\n\n//~ Slash commands / proactive posts: parseSlashCommand + respondToSlashCommand\n//~ and the OAuth helpers also ship in @pikku/gateway-slack; wire a slash-command\n//~ route only when the brief asks for one.\n" }, { "name": "gateway-whatsapp", "title": "WhatsApp Business gateway (Meta Cloud API webhook) — inbound messages linked to app users", "when": "The brief wants the app reachable over WhatsApp — a business number users message, a WhatsApp bot/assistant, order updates over WhatsApp. Business tier ONLY (Meta Cloud API webhook — deploys everywhere); never Baileys (personal-tier listener, does not deploy). Install first: `bun add @pikku/addon-whatsapp`.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "PREREQS (do these first, they are not optional):\n1. `bun add @pikku/addon-whatsapp` — ships the adapter, the Cloud API service,\n and the `WHATSAPP_CREDENTIALS` defineSecret (accessToken, phoneNumberId,\n verifyToken). Because it is a defineSecret, the platform AUTOMATICALLY shows\n it as blocked-until-set in the project's Secrets dashboard and deploy gate —\n do NOT enumerate secret fields in chat, just build.\n2. Migration — sender identities link to app users via a dedicated table\n (db/sqlite/NNNN-gateway-identity.sql at the PROJECT ROOT):\n CREATE TABLE gateway_identity (\n channel TEXT NOT NULL,\n external_id TEXT NOT NULL,\n user_id TEXT NOT NULL REFERENCES user(id),\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY (channel, external_id)\n );\n Run `pikku db migrate` after writing it.\n3. Register `<your public origin>` + the route below as the webhook URL in the\n Meta dashboard. Meta sends a GET challenge first; the adapter answers it\n automatically once WHATSAPP_CREDENTIALS is set.", "source": "packages/cli/examples/wires/gateway-whatsapp.ts", "content": "//~ name: gateway-whatsapp\n//~ title: WhatsApp Business gateway (Meta Cloud API webhook) — inbound messages linked to app users\n//~ when: The brief wants the app reachable over WhatsApp — a business number users message, a WhatsApp bot/assistant, order updates over WhatsApp. Business tier ONLY (Meta Cloud API webhook — deploys everywhere); never Baileys (personal-tier listener, does not deploy). Install first: `bun add @pikku/addon-whatsapp`.\n//~ steps:\n//~ PREREQS (do these first, they are not optional):\n//~ 1. `bun add @pikku/addon-whatsapp` — ships the adapter, the Cloud API service,\n//~ and the `WHATSAPP_CREDENTIALS` defineSecret (accessToken, phoneNumberId,\n//~ verifyToken). Because it is a defineSecret, the platform AUTOMATICALLY shows\n//~ it as blocked-until-set in the project's Secrets dashboard and deploy gate —\n//~ do NOT enumerate secret fields in chat, just build.\n//~ 2. Migration — sender identities link to app users via a dedicated table\n//~ (db/sqlite/NNNN-gateway-identity.sql at the PROJECT ROOT):\n//~ CREATE TABLE gateway_identity (\n//~ channel TEXT NOT NULL,\n//~ external_id TEXT NOT NULL,\n//~ user_id TEXT NOT NULL REFERENCES user(id),\n//~ created_at TEXT NOT NULL DEFAULT (datetime('now')),\n//~ PRIMARY KEY (channel, external_id)\n//~ );\n//~ Run `pikku db migrate` after writing it.\n//~ 3. Register `<your public origin>` + the route below as the webhook URL in the\n//~ Meta dashboard. Meta sends a GET challenge first; the adapter answers it\n//~ automatically once WHATSAPP_CREDENTIALS is set.\nimport type { GatewayInboundMessage } from '@pikku/core/gateway'\nimport { wireGateway } from '@pikku/core/gateway'\nimport {\n WhatsAppGatewayAdapter,\n WhatsappService,\n whatsappSecretsSchema,\n} from '@pikku/addon-whatsapp'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ HANDLER — a normal pikkuSessionlessFunc. The runner parses the Meta payload\n//~ into a GatewayInboundMessage (senderId = the sender's phone number) BEFORE\n//~ calling you; returning { text } auto-sends the reply back over WhatsApp.\n//~ No zod input/output here — gateway handlers are wired by the runner, and the\n//~ input type IS GatewayInboundMessage.\nexport const onWhatsAppMessage = pikkuSessionlessFunc({\n expose: false,\n description: 'Handle an inbound WhatsApp message',\n func: async ({ kysely, logger }, message: GatewayInboundMessage) => {\n const senderId = message.senderId\n const contactName = (message.metadata?.contactName as string | undefined) ?? senderId\n\n //~ LINK SENDER → APP USER. TWO POLICIES — the product brief decides which\n //~ (the planner asks when messaging is in scope). KEEP ONE, DELETE THE OTHER.\n const identity = await kysely\n .selectFrom('gatewayIdentity')\n .select(['userId'])\n .where('channel', '=', 'whatsapp')\n .where('externalId', '=', senderId)\n .executeTakeFirst()\n\n let userId = identity?.userId\n\n //~ POLICY A — OPEN (message creates the user): first message from an unknown\n //~ number creates a data-only app user (no password/account row — same idea\n //~ as seeded users; they can later claim the account via the app's own flow).\n if (!userId) {\n const user = await kysely\n .insertInto('user')\n .values({\n //~ match the app's user schema/annotations — these are the Better Auth\n //~ defaults; the synthetic unique email marks a gateway-born user.\n id: crypto.randomUUID(),\n name: contactName,\n email: `whatsapp-${senderId}@gateway.invalid`,\n emailVerified: false,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n .returning(['id'])\n .executeTakeFirstOrThrow()\n await kysely\n .insertInto('gatewayIdentity')\n .values({ channel: 'whatsapp', externalId: senderId, userId: user.id })\n .execute()\n userId = user.id\n logger.info(`whatsapp gateway: created user ${userId} for ${senderId}`)\n }\n\n //~ POLICY B — CLOSED (user must already exist): the app links numbers itself\n //~ (e.g. a \"connect WhatsApp\" screen inserting into gateway_identity, or an\n //~ admin doing it). Unknown sender → polite rejection, nothing created:\n // if (!userId) {\n // return { text: 'This number is not registered. Please sign up in the app first.' }\n // }\n\n //~ DOMAIN LOGIC — everything below is the actual app: act on message.text\n //~ for the linked userId (create the order, answer the question, run the\n //~ agent...). Replace this echo with the brief's real behaviour.\n logger.info(`whatsapp gateway: ${userId} said ${message.text}`)\n return { text: `Got it, ${contactName}!` }\n },\n})\n\n//~ WIRING — in real code this lives in its own wires/gateway/whatsapp.wiring.ts\n//~ (the handler above in its own *.function.ts). The adapter is a FACTORY\n//~ (services) => adapter: it needs the Cloud API credentials, which only exist\n//~ after boot; pikku resolves it lazily on the first webhook and caches it.\n//~ Route is served at /api/gateways/whatsapp (webhook gateways are auth:false\n//~ and register GET (Meta challenge) + POST).\nwireGateway({\n name: 'whatsapp',\n type: 'webhook',\n route: '/gateways/whatsapp',\n adapter: async ({ secrets }) => {\n const creds = whatsappSecretsSchema.parse(\n (await secrets.getSecret('WHATSAPP_CREDENTIALS')).reveal(),\n )\n return new WhatsAppGatewayAdapter(new WhatsappService(creds), creds.verifyToken)\n },\n func: onWhatsAppMessage,\n auth: false,\n})\n\n//~ PROACTIVE sends (outside a reply — e.g. from a workflow or cron): call the\n//~ addon's `messagesSend` function via rpc like any other addon function\n//~ (`rpc.invoke('messagesSend', { to, text })`), or use wire.gateway.send inside\n//~ the handler for multi-message replies. NEVER hand-roll fetch to\n//~ graph.facebook.com.\n" }, { "name": "infinite-list-query", "title": "Paginated/infinite-scroll list RPC (pikkuListFunc + usePikkuInfiniteQuery)", "when": "A page needs to READ rows a user scrolls through and the collection can grow beyond one page (tables, card grids, search results). For a small fixed list, use `list-query` instead. Returning `nextCursor` is the whole opt-in — the generated usePikkuInfiniteQuery hook detects this output shape structurally, so pass `{ limit }` from the page and never a cursor.", "lang": "ts", "entity": "item", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/data/infinite-list-query.ts", "content": "//~ name: infinite-list-query\n//~ title: Paginated/infinite-scroll list RPC (pikkuListFunc + usePikkuInfiniteQuery)\n//~ entity: item\n//~ when: A page needs to READ rows a user scrolls through and the collection can grow beyond one page (tables, card grids, search results). For a small fixed list, use `list-query` instead. Returning `nextCursor` is the whole opt-in — the generated usePikkuInfiniteQuery hook detects this output shape structurally, so pass `{ limit }` from the page and never a cursor.\n\n// ===== FILE: packages/functions/src/functions/list-items.function.ts =====\nimport { pikkuListFunc } from '#pikku/function'\n\n//~ pikkuListFunc<F, Row> is the canonical shape for a paginated list: input\n//~ is ListInput<F> (cursor/limit/sort/filter/search — all optional except what\n//~ you use), output is ListOutput<Row> ({rows, nextCursor, totalCount}). This\n//~ exact output shape is what the codegen'd usePikkuInfiniteQuery hook detects\n//~ structurally — no separate opt-in, just return `nextCursor`.\ninterface Item {\n id: string\n label: string\n}\n\nexport const listItems = pikkuListFunc<{ status?: string }, Item>({\n expose: true, //~ generates the typed client RPC consumed by usePikkuInfiniteQuery\n readonly: true,\n auth: true,\n description: 'List items for the signed-in user, paginated.',\n //~ `input` is inferred as ListInput<{ status?: string }> from the generics\n //~ above — never re-annotate it inline (same rule as zod-typed pikkuFunc).\n func: async ({ kysely }, input, { session }) => {\n const limit = input.limit ?? 20\n //~ Cursor here is a plain numeric offset encoded as a string — any opaque\n //~ string works as long as you can turn it back into a query position.\n const offset = input.cursor ? Number(input.cursor) : 0\n\n //~ Swap `item` for your own table after adding it to db.ts + a db/sqlite\n //~ migration. ALWAYS scope to session.userId — never return another user's rows.\n //~ Shared filtered base for BOTH the page fetch and the count. $if adds the\n //~ optional filter in one chain (no mutable `let query`, no cast) — reference\n //~ the typed column 'status', never a raw sql`` fragment.\n const base = kysely\n .selectFrom('item')\n .where('userId', '=', session!.userId)\n .$if(!!input.filter?.status, (qb) => qb.where('status', '=', input.filter!.status!))\n\n const rows = await base.orderBy('createdAt', 'desc').offset(offset).limit(limit).execute()\n const totalCount = await base\n .select((eb) => eb.fn.countAll<number>().as('count'))\n .executeTakeFirstOrThrow()\n\n const nextOffset = offset + rows.length\n return {\n rows: rows.map((r) => ({ id: r.id, label: r.name })),\n nextCursor: nextOffset < totalCount.count ? String(nextOffset) : null,\n totalCount: totalCount.count,\n }\n },\n})\n\n//~ Frontend: usePikkuInfiniteQuery is generated automatically once\n//~ reactQueryFile is configured — no extra setup for list functions\n//~ specifically. Pair the \"Load more\" trigger with an IntersectionObserver\n//~ sentinel for true infinite scroll instead of a manual button if the UX\n//~ calls for it.\n//~\n//~ import { usePikkuInfiniteQuery } from '.pikku/pikku-react-query.gen'\n//~\n//~ function ItemList() {\n//~ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = usePikkuInfiniteQuery(\n//~ 'listItems',\n//~ { limit: 20 }, // never pass cursor here — the hook manages it\n//~ )\n//~ const rows = data?.pages.flatMap((page) => page.rows) ?? []\n//~ return (\n//~ <>\n//~ {rows.map((row) => <div key={row.id}>{row.label}</div>)}\n//~ {hasNextPage && (\n//~ <button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>\n//~ Load more\n//~ </button>\n//~ )}\n//~ </>\n//~ )\n//~ }\n" }, { "name": "list-query", "title": "List + detail read RPCs (expose + auth + zod + kysely)", "when": "A page needs to READ rows from the DB. Writes both the list query and the detail-by-id query for the entity.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/data/list-query.ts", "content": "//~ name: list-query\n//~ title: List + detail read RPCs (expose + auth + zod + kysely)\n//~ when: A page needs to READ rows from the DB. Writes both the list query and the detail-by-id query for the entity.\n//~ entity: todo\n//~ The example entity is `todo` throughout — table, generated zod, RPC names and file\n//~ paths. That consistency is load-bearing now that this is WRITTEN rather than pasted:\n//~ `--entity invoice` rewrites every spelling at once, so a scaffold that reached for\n//~ the always-present `user` table in one place would land half-renamed and typecheck\n//~ against neither table. Keep every domain reference on the one example name.\n\n// ===== FILE: packages/functions/src/functions/list-todos.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n//~ CRITICAL: DB COLUMNS come from the GENERATED DB ZOD — re-listing a field that\n//~ EXISTS as a column by hand is the bug (it drifts from the schema). `pikku db`\n//~ generates `<Table>Z` (full row), `<Table>InsertZ` (write) and `<Table>PatchZ`\n//~ (partial) for EVERY table in `#pikku/db/zod.gen.js`. Compose them: `.pick()` the\n//~ columns you return, `.omit()` the ones you don't. Fields that are NOT columns —\n//~ computed/aggregated/joined (a count, sum, gap, joined label) — you DO declare\n//~ yourself via `.extend()` (or a plain z.object if the whole output is non-DB).\nimport { TodoZ } from '#pikku/db/zod.gen.js'\n\n//~ Input/output types come ONLY from these zod schemas — never inline generics\n//~ (pikkuFunc<In,Out>) and never annotate the func return type. The schema IS\n//~ the type and drives the generated client (usePikkuQuery('listTodos', ...)).\nexport const ListTodosInput = z.object({\n //~ Keep inputs small and validated. Optional search/paging args go here.\n search: z.string().optional(),\n})\n\nexport const ListTodosOutput = z.object({\n //~ Composed from the generated row zod: pick the returned columns, then extend\n //~ any computed field that is not a DB column.\n //~ NULLABLE columns: a `string | null` column picked here STAYS nullable — that\n //~ is correct. If you hand-write `z.string()` for a nullable column (or omit the\n //~ pick and retype it), the row's `string | null` won't match your required\n //~ `string` and you get `TS2769: No overload matches this call` ON THE func:\n //~ line. That error is a RETURN-vs-output mismatch (usually nullability) — it is\n //~ NOT the query chain, NOT `.orderBy()`, NOT `let query = ...; if (x) query =\n //~ query.where(...)` (all of those type-check fine). Fix the output SCHEMA to\n //~ match the column, never restructure the query.\n todos: z.array(TodoZ.pick({ id: true, title: true })),\n})\n\nexport const listTodos = pikkuFunc({\n expose: true, //~ generates the typed client RPC consumed by usePikkuQuery\n readonly: true, //~ read-only: declares this never writes\n auth: true, //~ requires a signed-in session (session is non-null below)\n description: 'List todos for the signed-in user.',\n input: ListTodosInput,\n output: ListTodosOutput,\n func: async ({ kysely }, input, { session }) => {\n //~ ALWAYS scope to session.userId — never return another user's rows.\n //~ Use $if for optional filters — one fluent chain, keeps column typing, no\n //~ mutable `let query` reassignment. Reference the typed column, never a raw\n //~ sql`` fragment.\n const rows = await kysely\n .selectFrom('todo')\n .select(['id', 'title'])\n .where('userId', '=', session!.userId)\n .$if(!!input.search, (qb) => qb.where('title', 'like', `%${input.search}%`))\n .execute()\n //~ Rows come back exactly as the generated schema types them: ISO strings by\n //~ default, real Date/boolean if the column kind is declared in\n //~ db/annotations.ts (see the update-mutation scaffold). Match your output\n //~ schema to that type — do not hand-map `r.done === 1` or reparse strings.\n return { todos: rows }\n },\n})\n\n// ===== FILE: packages/functions/src/functions/get-todo.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\nimport { NotFoundError } from '@pikku/core/errors' //~ typed error → correct 404\nimport { TodoZ } from '#pikku/db/zod.gen.js'\n\n//~ A detail page (usePikkuQuery('getTodo', { id })) needs a single row. The idiom is\n//~ `.executeTakeFirstOrThrow(() => new NotFoundError(...))` — ONE call that returns the\n//~ row or throws the TYPED pikku error. Do NOT write `.executeTakeFirst()` then a\n//~ separate `if (!row) throw` — that's two steps for what the OrThrow callback does in\n//~ one, and a bare `throw new Error()` instead of a typed pikku error becomes an opaque\n//~ 500 (see the pikku-errors rule). Scope the WHERE to the tenant (activeOrganizationId\n//~ for org apps, else session.userId) so a valid id from ANOTHER org still 404s.\nexport const GetTodoInput = z.object({ id: z.string() })\nexport const GetTodoOutput = TodoZ.pick({ id: true, title: true })\n\nexport const getTodo = pikkuFunc({\n expose: true,\n readonly: true,\n auth: true,\n description: 'Get one todo by id (scoped to the caller).',\n input: GetTodoInput,\n output: GetTodoOutput,\n func: async ({ kysely }, input, { session }) => {\n //~ selectAll() is fine when output = the full row zod; here we .select the picked\n //~ columns. The two .where clauses = the row AND the tenant guard.\n return await kysely\n .selectFrom('todo')\n .select(['id', 'title'])\n .where('id', '=', input.id)\n .where('userId', '=', session!.userId) //~ swap for `.where('organizationId','=',session!.activeOrganizationId!)` in an org app\n .executeTakeFirstOrThrow(() => new NotFoundError('Todo not found'))\n },\n})\n" }, { "name": "mcp-tool", "title": "MCP tool — expose an app action to an external AI client (Claude, etc.)", "when": "You want an OUTSIDE AI assistant to drive the app over the Model Context Protocol (create a record, look something up). For an IN-APP assistant/chatbot use {name: ai-agent} instead.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/mcp-tool.ts", "content": "//~ name: mcp-tool\n//~ title: MCP tool — expose an app action to an external AI client (Claude, etc.)\n//~ when: You want an OUTSIDE AI assistant to drive the app over the Model Context Protocol (create a record, look something up). For an IN-APP assistant/chatbot use {name: ai-agent} instead.\n//~ entity: todo\n\n// ===== FILE: packages/functions/src/functions/create-todo-tool.function.ts =====\nimport { z } from 'zod'\nimport { pikkuMCPToolFunc } from '#pikku/mcp'\n\n//~ An MCP tool is AUTO-SERVED from its export — there is NO wire*() call (unlike\n//~ resources/prompts). Give it a clear `description` (the client picks tools by\n//~ it) and an `input` zod. The 3rd arg carries `rpc` — CALL YOUR EXISTING RPCs\n//~ through it instead of re-implementing logic; the return is MCP content parts.\n//~ input is a named const, never inline at the input: site (PKU489).\nexport const CreateTodoToolInput = z.object({ title: z.string(), userId: z.string() })\n\nexport const createTodoTool = pikkuMCPToolFunc({\n description: 'Create a todo with a title for the current user.',\n input: CreateTodoToolInput,\n func: async (_services, input, { rpc }) => {\n //~ Reuse the app's own createTodo RPC — one source of truth for the write.\n const result = await rpc.invoke('createTodo', input)\n return [\n { type: 'text' as const, text: `Created todo \"${result.todo.title}\" (${result.todo.id})` },\n ]\n },\n})\n" }, { "name": "public-facing-rpc", "title": "Public RPC (no sign-in) — anonymous form / webhook write", "when": "A function must work WITHOUT a logged-in user — a public page's contact / waitlist / signup / feedback / newsletter form, an inbound webhook, a health check, or ANY endpoint the task calls \"public\", \"without signing in\", \"unauthenticated\", \"anonymous\", or \"anyone can\". Reach for THIS instead of update-mutation / list-query whenever the caller is not signed in.", "lang": "ts", "entity": "contactMessage", "deferUntil": "", "requires": [], "steps": "═══ CLIENT SIDE — a PUBLIC form on an UNAUTHENTICATED route ═══\nThe page sits on a public route (no auth guard) and the call needs NO session. Use\nusePikkuMutation with isPending/error for the button + INLINE feedback — never a toast,\nnever hand-managed useState:\n\n const mutation = usePikkuMutation('submitContactMessage', {\n onSuccess: () => { /* show inline success + reset the form */ },\n })\n // <Button loading={mutation.isPending} onClick={() => mutation.mutate({ name, email, message })}>\n // {mutation.error && <Text c=\"red\">{asI18n(mutation.error.message)}</Text>}\n // {mutation.isSuccess && <Text c=\"green\">{t('contact.sent')}</Text>}", "source": "packages/cli/examples/wires/public-facing-rpc.ts", "content": "//~ name: public-facing-rpc\n//~ title: Public RPC (no sign-in) — anonymous form / webhook write\n//~ entity: contactMessage\n//~ when: A function must work WITHOUT a logged-in user — a public page's contact / waitlist / signup / feedback / newsletter form, an inbound webhook, a health check, or ANY endpoint the task calls \"public\", \"without signing in\", \"unauthenticated\", \"anonymous\", or \"anyone can\". Reach for THIS instead of update-mutation / list-query whenever the caller is not signed in.\n//~ steps:\n//~ ═══ CLIENT SIDE — a PUBLIC form on an UNAUTHENTICATED route ═══\n//~ The page sits on a public route (no auth guard) and the call needs NO session. Use\n//~ usePikkuMutation with isPending/error for the button + INLINE feedback — never a toast,\n//~ never hand-managed useState:\n//~\n//~ const mutation = usePikkuMutation('submitContactMessage', {\n//~ onSuccess: () => { /* show inline success + reset the form */ },\n//~ })\n//~ // <Button loading={mutation.isPending} onClick={() => mutation.mutate({ name, email, message })}>\n//~ // {mutation.error && <Text c=\"red\">{asI18n(mutation.error.message)}</Text>}\n//~ // {mutation.isSuccess && <Text c=\"green\">{t('contact.sent')}</Text>}\n// ===== FILE: packages/functions/src/functions/submit-contact-message.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n//~ INPUT columns come from the GENERATED DB zod (`#pikku/db/zod.gen.js`) — `<Table>InsertZ`\n//~ for a create, `<Table>Z.pick()` for what you return. Re-typing a column by hand drifts.\nimport { ContactMessageInsertZ, ContactMessageZ } from '#pikku/db/zod.gen.js' //~ swap for YOUR table\n\n//~ ── DECIDE PUBLIC vs SIGNED-IN FIRST — it picks the primitive, and the default is wrong here ──\n//~ The DEFAULT primitive, pikkuFunc, REQUIRES a session: an anonymous caller gets a 403\n//~ (\"Authentication required\") and the request never reaches your code. So:\n//~ • Called only by a signed-in user (a dashboard action, \"my\" data)? → pikkuFunc.\n//~ A session is required; scope writes by session.userId. See the update-mutation scaffold.\n//~ • Called by ANYONE, including logged-out visitors — a public page's form, a webhook,\n//~ a health check? → pikkuSessionlessFunc, as below (auth: false). There is NO session,\n//~ so you CANNOT scope by session.userId and you MUST validate every field yourself.\n//~ This is the #1 public-form bug: a pikkuFunc behind a public form 403s every anonymous\n//~ visitor, so the form silently never submits. \"Public / without signing in\" is a BACKEND\n//~ fact — it means pikkuSessionlessFunc, not just an unauthenticated frontend route.\n\n//~ INPUT = the real columns the form sends, picked off the generated Insert zod (so the types\n//~ track the DB) and tightened with .extend. NEVER ship an empty/omitted input schema — an\n//~ empty z.object({}) rejects every field the form sends with a 422 and the form can't submit.\nexport const SubmitContactMessageInput = ContactMessageInsertZ.pick({\n name: true,\n email: true,\n message: true,\n}).extend({\n name: z.string().min(1),\n email: z.string().email(),\n message: z.string().min(1).max(5000),\n})\n\n//~ OUTPUT = only what the client needs back — an id is enough to confirm the write landed.\nexport const SubmitContactMessageOutput = ContactMessageZ.pick({ id: true })\n\nexport const submitContactMessage = pikkuSessionlessFunc({\n expose: true,\n //~ PUBLIC — no session required. If you ALSO add an explicit HTTP wiring, it must be\n //~ auth: false too; a kind⇔auth mismatch (sessionless func wired auth: true) is a hard\n //~ PKU573 error.\n auth: false,\n description: 'Accept a contact-form submission from an anonymous visitor.',\n input: SubmitContactMessageInput,\n output: SubmitContactMessageOutput,\n //~ No `permissions` and no session scoping — the endpoint is intentionally open. A public\n //~ write STILL needs a real table + migration: add db/postgres/<n>-contact-message.sql\n //~ (and db/sqlite/<n>-contact-message.sql for a libSQL stage) creating the table BEFORE\n //~ this ships, or the insert 500s at runtime.\n func: async ({ kysely }, input) => {\n const row = await kysely\n .insertInto('contactMessage')\n .values({ name: input.name, email: input.email, message: input.message })\n .returning(['id'])\n .executeTakeFirstOrThrow()\n return { id: row.id }\n },\n})\n" }, { "name": "queue-worker", "title": "Background queue worker (wireQueueWorker + sessionless func)", "when": "Work that should run OFF the request — send-later, image/report processing, a retryable job. Enqueue from any function; the worker runs it in the background. For a MULTI-STEP durable process use {name: workflow} instead.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "NEXT STEP — a worker only runs when something ENQUEUES onto it. Nothing does yet.\nFrom whichever function starts the work (the RPC behind a button, an HTTP wire, a\nscheduler), use the injected `queue` service — the first argument is the `name` from\nwireQueueWorker, the second is the func's input:\n await queue.add('todo-reminders', { todoId, userId })\nDo NOT reach for a scheduler to trigger this. A wireScheduler fires on a clock and\nnobody can start it on demand; if the user asked for a button, the button's function\ncalls queue.add and returns immediately. A schedule is only right when the trigger\ngenuinely IS the clock.\nTo report progress back while the job runs, publish to the event hub from inside the\nworker and subscribe in the UI — see {name: sse}.", "source": "packages/cli/examples/wires/queue-worker.ts", "content": "//~ name: queue-worker\n//~ title: Background queue worker (wireQueueWorker + sessionless func)\n//~ when: Work that should run OFF the request — send-later, image/report processing, a retryable job. Enqueue from any function; the worker runs it in the background. For a MULTI-STEP durable process use {name: workflow} instead.\n//~ entity: todo\n//~ steps:\n//~ NEXT STEP — a worker only runs when something ENQUEUES onto it. Nothing does yet.\n//~ From whichever function starts the work (the RPC behind a button, an HTTP wire, a\n//~ scheduler), use the injected `queue` service — the first argument is the `name` from\n//~ wireQueueWorker, the second is the func's input:\n//~ await queue.add('todo-reminders', { todoId, userId })\n//~ Do NOT reach for a scheduler to trigger this. A wireScheduler fires on a clock and\n//~ nobody can start it on demand; if the user asked for a button, the button's function\n//~ calls queue.add and returns immediately. A schedule is only right when the trigger\n//~ genuinely IS the clock.\n//~ To report progress back while the job runs, publish to the event hub from inside the\n//~ worker and subscribe in the UI — see {name: sse}.\n\n// ===== FILE: packages/functions/src/functions/process-todo-reminder.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ The job payload IS the func input. A worker is sessionless (no user session)\n//~ — pass every id it needs in the payload. `deploy: 'server'` keeps it off the\n//~ serverless path. Keep the output small (it's the job result).\nexport const ProcessTodoReminderInput = z.object({\n todoId: z.string(),\n userId: z.string(),\n})\n//~ output is a named const too — never inline at the output: site (PKU489).\nexport const ProcessTodoReminderOutput = z.object({ processed: z.boolean(), message: z.string() })\n\nexport const processTodoReminder = pikkuSessionlessFunc({\n deploy: 'server',\n input: ProcessTodoReminderInput,\n output: ProcessTodoReminderOutput,\n func: async ({ kysely, logger }, { todoId, userId }) => {\n //~ Do the real work — read/update rows, call a service, send an email.\n const todo = await kysely\n .selectFrom('todo')\n .select(['id', 'title', 'done'])\n .where('id', '=', todoId)\n .where('userId', '=', userId)\n .executeTakeFirst()\n if (!todo) return { processed: false, message: `Todo ${todoId} not found` }\n if (todo.done) return { processed: true, message: `Todo ${todoId} already done` }\n logger.info(`Reminder: \"${todo.title}\" is due`)\n return { processed: true, message: `Reminder sent for ${todo.title}` }\n },\n})\n\n// ===== FILE: packages/functions/src/wires/queue/todo-reminders.queue.ts =====\nimport { wireQueueWorker } from '#pikku/queue'\nimport { processTodoReminder } from '../../functions/process-todo-reminder.function.js'\n\n//~ `name` is a literal string — it is the queue other code enqueues onto.\n//~ ENQUEUE a job from any other function via the injected queue service:\n//~ await queue.add('todo-reminders', { todoId, userId })\nwireQueueWorker({\n name: 'todo-reminders',\n func: processTodoReminder,\n})\n" }, { "name": "scenario", "title": "Scenario — prove the app's CORE journey works end-to-end over real RPCs", "when": "PHASE 7.5, and EVERY app needs one. The single core-journey STARTER — a signed-in actor drives the app's ONE main happy path over the real transport. Start here, then pull the SPECIFIC extra patterns you need by name — `--name scenario-crud` (full round-trip), `scenario-relational` (parent→child link), `scenario-transition` (status move), `scenario-permissions` (denied path), `scenario-multitenant` (tenant isolation), `scenario-scheduled` (cron/eventual). Every step must ASSERT its outcome — read a write back and THROW if the effect is not there; a step that only proves \"the RPC didn't throw\" is a fake gate. NEVER put a userId/organizationId/tenantId in {data}: identity comes from the actor's SESSION, and supplying your own scope makes write+read agree by construction and hides the exact bug this proves. Point each step at YOUR RPC names and rename `visitor` to one of the personas you declared.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "A scenario tells ONE happy-path story as a synthetic persona (an \"actor\" — pikku\nmaterialises one actor per persona you declared with definePersonas, so there is\nno actor list to maintain and nothing ships by default — `visitor` in the written\nfile is a placeholder, rename it to one of YOUR declared persona ids). Each\nscenario.do step calls a REAL exposed RPC BY NAME with the actor's session\ncookie; the {data} must satisfy that RPC's input. Rename the written file to YOUR\ncore journey calling YOUR RPCs. Types are GENERICS <Input, Output> — a scenario is\na story, not a schema'd function, so there is NO input/output zod here (and no\nPKU489/PKU456).\n\nAUTO-REGISTERS from the export (no wire call): pikku codegen discovers every\npikkuScenario in a *.scenario.ts. After writing it, run pikku-verify ONCE to\nregister, then pikku-scenario to run it. A failing step names the RPC + error —\nfix the pikku function behind it, NEVER weaken the scenario.\n\n⚠️ THE 3RD ARG IS `{ scenario, actors }` — NOT `workflow`. The scenario DSL is\nbound to `scenario` (scenario.do / scenario.expectEventually / …). `workflow.*`\nis the DSL you use INSIDE a pikkuWorkflow func — it does NOT exist here and is\n`undefined` at runtime. Destructure `{ scenario, actors }`.\n\n⚠️ ASSERT THE OUTCOME — a step that only checks \"the RPC didn't throw\" PROVES\nNOTHING. The #1 way a build ships broken-but-green is a scenario that creates a\nrow, calls list, and never checks the row is IN the list: `createContact` can\nreturn 200 while `listContacts` returns [] (wrong scope, uncommitted write, a\ntenant filter that doesn't match) and BOTH \"pass\". So after every WRITE, READ it\nback and THROW if the effect isn't there — assert the created id appears in the\nlist, assert an update is reflected, assert a status move actually moved. The\nresult of every scenario.do is returned to you; use it. An un-asserted read is a\nfake gate. (Identity comes from the actor's SESSION — NEVER pass a userId /\norganizationId / tenantId in {data}: a scenario that supplies its own scope makes\nwrite+read agree by construction and HIDES the exact session-scoping bug this\ngate exists to catch. If an RPC's input schema even HAS such a field, that\nfunction is wrong — fix it to read the id from the session, don't feed it here.)\n\nTHE SCENARIO VERBS (the 3rd arg `scenario` has more than `.do`):\n • scenario.do(step, rpc, data, { actor }) — call an RPC, return its output.\n • scenario.expectEventually(step, rpc, data, pred, { actor, within }) — POLL the RPC\n until `pred(output)` is true (or `within` ms elapse). The clean way to assert a\n write landed: `scenario.expectEventually('shows in list', 'listTodos', {}, o => o.todos.some(t => t.id === id), { actor })`.\n • scenario.expectError(step, rpc, data, { actor }) — SUCCEEDS only when the RPC\n THROWS; returns the message. Use for the permission-DENIED path (a member hitting\n an admin-only action) — never treat the throw as a failure.\n • scenario.runScheduledTask('taskName') — fire a cron/scheduled task\n IMMEDIATELY so a scenario can cover it (no waiting for the schedule). Queue jobs\n run when their enqueue RPC is called in a step. ONLY a `scenario.sleep` step is\n genuinely un-drivable synchronously — so cover cron + queue functions too.\n\nTHE THREE STEPS THE WRITTEN SCENARIO IS MADE OF, in order:\n 1. The first WRITE the user makes (create the app's main entity). Call the real\n create RPC; {data} must match that function's input schema. NO identity id in\n {data} — the actor's session owns the row.\n 2. READ IT BACK AND ASSERT. The list MUST now contain step 1's row. This is the\n assertion that would have caught \"create returns 200 but the row never shows\".\n If it's missing, THROW — that is a real, must-fix app break.\n 3. An UPDATE that completes the journey, then ASSERT it took effect.\nKeep it to the ONE core happy path. Return anything useful for assertions.\n\n═══ THE FEATURE — group the scenarios for ONE AREA of the app and SAY WHAT IT PROMISES ═══\nOne pikkuFeature per AREA, alone in its own `test/features/<domain>.feature.ts`,\nimporting each scenario from its own `test/scenarios/*.scenario.ts` file.\nA feature is NOT a milestone: it is named for something a person can DO here and it\noutlives the milestone that started it, so a later milestone extending this area adds\nits scenarios to THIS file rather than opening a second one beside it. Its `name` is\nthat area in the app's own words, and `scenarios` grows as you write each one.\nA loose pile of scenario exports runs, but says nothing about what the app claims\nto do; a feature is the acceptance criteria in the DOMAIN's words — name and\ndescription written for the person who asked for the app, not for the test runner.\nGreen feature = that promise genuinely holds, not merely compiles.\nEVERY PERSONA GETS DRIVEN AS ITSELF: the personas you declared with definePersonas\n(the definePersonas call lists them) materialise one actor each, so the cafe\nowner's journey runs `{ actor: actors.cafeOwner }` and the reviewer's runs as the\nreviewer. Driving every journey as ONE actor makes write-then-read agree by\nconstruction and hides the scoping/permission bugs this gate exists to catch; a\npersona no scenario names is a person whose experience is UNPROVEN.", "source": "packages/cli/examples/scenarios/scenario.ts", "content": "//~ name: scenario\n//~ title: Scenario — prove the app's CORE journey works end-to-end over real RPCs\n//~ entity: todo\n//~ when: PHASE 7.5, and EVERY app needs one. The single core-journey STARTER — a signed-in actor drives the app's ONE main happy path over the real transport. Start here, then pull the SPECIFIC extra patterns you need by name — `--name scenario-crud` (full round-trip), `scenario-relational` (parent→child link), `scenario-transition` (status move), `scenario-permissions` (denied path), `scenario-multitenant` (tenant isolation), `scenario-scheduled` (cron/eventual). Every step must ASSERT its outcome — read a write back and THROW if the effect is not there; a step that only proves \"the RPC didn't throw\" is a fake gate. NEVER put a userId/organizationId/tenantId in {data}: identity comes from the actor's SESSION, and supplying your own scope makes write+read agree by construction and hides the exact bug this proves. Point each step at YOUR RPC names and rename `visitor` to one of the personas you declared.\n//~ steps:\n//~ A scenario tells ONE happy-path story as a synthetic persona (an \"actor\" — pikku\n//~ materialises one actor per persona you declared with definePersonas, so there is\n//~ no actor list to maintain and nothing ships by default — `visitor` in the written\n//~ file is a placeholder, rename it to one of YOUR declared persona ids). Each\n//~ scenario.do step calls a REAL exposed RPC BY NAME with the actor's session\n//~ cookie; the {data} must satisfy that RPC's input. Rename the written file to YOUR\n//~ core journey calling YOUR RPCs. Types are GENERICS <Input, Output> — a scenario is\n//~ a story, not a schema'd function, so there is NO input/output zod here (and no\n//~ PKU489/PKU456).\n//~\n//~ AUTO-REGISTERS from the export (no wire call): pikku codegen discovers every\n//~ pikkuScenario in a *.scenario.ts. After writing it, run pikku-verify ONCE to\n//~ register, then pikku-scenario to run it. A failing step names the RPC + error —\n//~ fix the pikku function behind it, NEVER weaken the scenario.\n//~\n//~ ⚠️ THE 3RD ARG IS `{ scenario, actors }` — NOT `workflow`. The scenario DSL is\n//~ bound to `scenario` (scenario.do / scenario.expectEventually / …). `workflow.*`\n//~ is the DSL you use INSIDE a pikkuWorkflow func — it does NOT exist here and is\n//~ `undefined` at runtime. Destructure `{ scenario, actors }`.\n//~\n//~ ⚠️ ASSERT THE OUTCOME — a step that only checks \"the RPC didn't throw\" PROVES\n//~ NOTHING. The #1 way a build ships broken-but-green is a scenario that creates a\n//~ row, calls list, and never checks the row is IN the list: `createContact` can\n//~ return 200 while `listContacts` returns [] (wrong scope, uncommitted write, a\n//~ tenant filter that doesn't match) and BOTH \"pass\". So after every WRITE, READ it\n//~ back and THROW if the effect isn't there — assert the created id appears in the\n//~ list, assert an update is reflected, assert a status move actually moved. The\n//~ result of every scenario.do is returned to you; use it. An un-asserted read is a\n//~ fake gate. (Identity comes from the actor's SESSION — NEVER pass a userId /\n//~ organizationId / tenantId in {data}: a scenario that supplies its own scope makes\n//~ write+read agree by construction and HIDES the exact session-scoping bug this\n//~ gate exists to catch. If an RPC's input schema even HAS such a field, that\n//~ function is wrong — fix it to read the id from the session, don't feed it here.)\n//~\n//~ THE SCENARIO VERBS (the 3rd arg `scenario` has more than `.do`):\n//~ • scenario.do(step, rpc, data, { actor }) — call an RPC, return its output.\n//~ • scenario.expectEventually(step, rpc, data, pred, { actor, within }) — POLL the RPC\n//~ until `pred(output)` is true (or `within` ms elapse). The clean way to assert a\n//~ write landed: `scenario.expectEventually('shows in list', 'listTodos', {}, o => o.todos.some(t => t.id === id), { actor })`.\n//~ • scenario.expectError(step, rpc, data, { actor }) — SUCCEEDS only when the RPC\n//~ THROWS; returns the message. Use for the permission-DENIED path (a member hitting\n//~ an admin-only action) — never treat the throw as a failure.\n//~ • scenario.runScheduledTask('taskName') — fire a cron/scheduled task\n//~ IMMEDIATELY so a scenario can cover it (no waiting for the schedule). Queue jobs\n//~ run when their enqueue RPC is called in a step. ONLY a `scenario.sleep` step is\n//~ genuinely un-drivable synchronously — so cover cron + queue functions too.\n//~\n//~ THE THREE STEPS THE WRITTEN SCENARIO IS MADE OF, in order:\n//~ 1. The first WRITE the user makes (create the app's main entity). Call the real\n//~ create RPC; {data} must match that function's input schema. NO identity id in\n//~ {data} — the actor's session owns the row.\n//~ 2. READ IT BACK AND ASSERT. The list MUST now contain step 1's row. This is the\n//~ assertion that would have caught \"create returns 200 but the row never shows\".\n//~ If it's missing, THROW — that is a real, must-fix app break.\n//~ 3. An UPDATE that completes the journey, then ASSERT it took effect.\n//~ Keep it to the ONE core happy path. Return anything useful for assertions.\n//~\n//~ ═══ THE FEATURE — group the scenarios for ONE AREA of the app and SAY WHAT IT PROMISES ═══\n//~ One pikkuFeature per AREA, alone in its own `test/features/<domain>.feature.ts`,\n//~ importing each scenario from its own `test/scenarios/*.scenario.ts` file.\n//~ A feature is NOT a milestone: it is named for something a person can DO here and it\n//~ outlives the milestone that started it, so a later milestone extending this area adds\n//~ its scenarios to THIS file rather than opening a second one beside it. Its `name` is\n//~ that area in the app's own words, and `scenarios` grows as you write each one.\n//~ A loose pile of scenario exports runs, but says nothing about what the app claims\n//~ to do; a feature is the acceptance criteria in the DOMAIN's words — name and\n//~ description written for the person who asked for the app, not for the test runner.\n//~ Green feature = that promise genuinely holds, not merely compiles.\n//~ EVERY PERSONA GETS DRIVEN AS ITSELF: the personas you declared with definePersonas\n//~ (the definePersonas call lists them) materialise one actor each, so the cafe\n//~ owner's journey runs `{ actor: actors.cafeOwner }` and the reviewer's runs as the\n//~ reviewer. Driving every journey as ONE actor makes write-then-read agree by\n//~ construction and hides the scoping/permission bugs this gate exists to catch; a\n//~ persona no scenario names is a person whose experience is UNPROVEN.\n\n// ===== FILE: packages/functions/test/scenarios/core-journey.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\nexport const coreJourneyScenario = pikkuScenario<void, { todoId: string }>({\n title: 'Core journey (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.visitor) {\n throw new Error(\n 'coreJourneyScenario needs run actors (visitor) — run via `pikku scenario run <environment>`',\n )\n }\n logger.debug('core journey starting')\n const created = await scenario.do(\n 'visitor creates a todo',\n 'createTodo',\n { title: 'Buy milk' },\n { actor: actors.visitor },\n )\n const list = await scenario.do(\n 'the todo shows in their list',\n 'listTodos',\n {},\n { actor: actors.visitor },\n )\n if (!list.todos.some((t) => t.id === created.todo.id)) {\n throw new Error(\n 'createTodo returned success but the new todo is NOT in listTodos — the write did not land where the read looks (scope/commit bug)',\n )\n }\n await scenario.do(\n 'visitor completes the todo',\n 'updateTodo',\n { id: created.todo.id, done: true },\n { actor: actors.visitor },\n )\n const after = await scenario.do(\n 'the todo now reads as done',\n 'listTodos',\n {},\n { actor: actors.visitor },\n )\n const updated = after.todos.find((t) => t.id === created.todo.id)\n if (!updated?.done) {\n throw new Error(\n 'updateTodo returned success but the todo did not actually change to done — the mutation did not persist',\n )\n }\n return { todoId: created.todo.id }\n },\n})\n\n// ===== FILE: packages/functions/test/features/todos.feature.ts =====\nimport { pikkuFeature } from '#pikku/scenarios'\nimport { coreJourneyScenario } from '../scenarios/core-journey.scenario.js'\n\nexport const todosFeature = pikkuFeature({\n name: 'Todos',\n description: 'A signed-in user can capture a todo, see it in their list, and complete it',\n tags: ['todos'],\n scenarios: [coreJourneyScenario],\n})\n" }, { "name": "scenario-crud", "title": "Scenario — full CRUD round-trip for one entity (create→list→update→delete, all asserted)", "when": "PHASE 7.5/7.6, once per CORE entity the user manages — pass that entity and it lands as its own `<entity>-crud.scenario.ts`. Proves a user can actually create, see, edit AND remove the object — each step read back and asserted, so a create that 200s into an empty list (scope/commit bug) fails LOUD. Pull `--name scenario` first for the starter; this is the backbone pattern on top of it.", "lang": "ts", "entity": "contact", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-crud.ts", "content": "//~ name: scenario-crud\n//~ title: Scenario — full CRUD round-trip for one entity (create→list→update→delete, all asserted)\n//~ entity: contact\n//~ when: PHASE 7.5/7.6, once per CORE entity the user manages — pass that entity and it lands as its own `<entity>-crud.scenario.ts`. Proves a user can actually create, see, edit AND remove the object — each step read back and asserted, so a create that 200s into an empty list (scope/commit bug) fails LOUD. Pull `--name scenario` first for the starter; this is the backbone pattern on top of it.\n\n// ===== FILE: packages/functions/test/scenarios/contact-crud.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ create → assert it's in the list → update → assert the change → delete →\n//~ assert it's gone. The 3rd arg is `{ scenario, actors }` (NOT `workflow`).\n//~ NO identity id in {data} — the actor's SESSION owns the row; feeding your own\n//~ userId/orgId makes write+read agree by construction and hides the real bug.\nexport const contactCrudScenario = pikkuScenario<void, { contactId: string }>({\n title: 'Contact CRUD (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.visitor) throw new Error('needs run actors — run via `pikku scenario run <env>`')\n logger.debug('contact crud starting')\n //~ CREATE — no identity id in {data}; the session owns it.\n const created = await scenario.do(\n 'create a contact',\n 'createContact',\n { name: 'Ada Lovelace', email: 'ada@example.com' },\n { actor: actors.visitor },\n )\n //~ READ-BACK — the new row MUST appear, or the write didn't land where the read looks.\n const list = await scenario.do(\n 'it shows in the list',\n 'listContacts',\n {},\n { actor: actors.visitor },\n )\n if (!list.contacts.some((c) => c.id === created.contact.id)) {\n throw new Error(\n 'createContact returned success but the contact is NOT in listContacts — scope/commit bug',\n )\n }\n //~ UPDATE — then read back and assert the change actually persisted.\n await scenario.do(\n 'rename the contact',\n 'updateContact',\n { id: created.contact.id, name: 'Ada King' },\n { actor: actors.visitor },\n )\n const afterEdit = await scenario.do(\n 'the rename persisted',\n 'getContact',\n { id: created.contact.id },\n { actor: actors.visitor },\n )\n if (afterEdit.contact.name !== 'Ada King') {\n throw new Error(\n 'updateContact returned success but the name did not change — mutation did not persist',\n )\n }\n //~ DELETE — then assert it's actually gone from the list.\n await scenario.do(\n 'delete the contact',\n 'deleteContact',\n { id: created.contact.id },\n { actor: actors.visitor },\n )\n const afterDelete = await scenario.do(\n 'it is gone from the list',\n 'listContacts',\n {},\n { actor: actors.visitor },\n )\n if (afterDelete.contacts.some((c) => c.id === created.contact.id)) {\n throw new Error(\n 'deleteContact returned success but the contact is STILL in the list — delete did not persist',\n )\n }\n return { contactId: created.contact.id }\n },\n})\n" }, { "name": "scenario-multitenant", "title": "Scenario — TENANT ISOLATION (org A's data is invisible to org B)", "when": "PHASE 7.5/7.6, for any app with the organization() plugin or an org/tenant scope. This is the scenario that PROVES multi-tenancy actually works: one org's user creates data, a DIFFERENT org's user lists it and must see NOTHING. A missing `where organizationId = session.activeOrganizationId` leaks every tenant's rows to every other — green CRUD hides it, this catches it. Needs two actors in DIFFERENT orgs (see below). Pull `--name scenario` first, and see `@pikku/better-auth`'s organization() plugin for the org setup + sign-up bootstrap.", "lang": "ts", "entity": "company", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-multitenant.ts", "content": "//~ name: scenario-multitenant\n//~ title: Scenario — TENANT ISOLATION (org A's data is invisible to org B)\n//~ entity: company\n//~ when: PHASE 7.5/7.6, for any app with the organization() plugin or an org/tenant scope. This is the scenario that PROVES multi-tenancy actually works: one org's user creates data, a DIFFERENT org's user lists it and must see NOTHING. A missing `where organizationId = session.activeOrganizationId` leaks every tenant's rows to every other — green CRUD hides it, this catches it. Needs two actors in DIFFERENT orgs (see below). Pull `--name scenario` first, and see `@pikku/better-auth`'s organization() plugin for the org setup + sign-up bootstrap.\n// ===== FILE: packages/functions/test/scenarios/tenant-isolation.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ TWO ACTORS, TWO ORGS. With the organization() plugin's sign-up bootstrap, every\n//~ signed-up user gets their OWN org made active — so the owner and `outsider` are\n//~ each alone in a separate org automatically (no id passed anywhere; the SESSION\n//~ carries activeOrganizationId). Declare `outsider` as a second persona in\n//~ definePersonas (its own id → its own derived email → distinct user → distinct\n//~ org). 3rd arg is `{ scenario, actors }` (NOT `workflow`).\n//~\n//~ The assertion has TWO halves and you need BOTH:\n//~ 1. the OWNER sees their own row (scope isn't SO tight it hides your own data), and\n//~ 2. the OUTSIDER does NOT (scope isn't SO loose it leaks across tenants).\nexport const tenantIsolationScenario = pikkuScenario<void, { companyId: string }>({\n title: 'Tenant isolation (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n const outsider = actors?.outsider ?? actors?.member\n if (!actors?.visitor || !outsider) {\n throw new Error(\n 'needs run actors (visitor + a second-org actor `outsider`/`member`) — run via `pikku scenario run <env>`',\n )\n }\n logger.debug('tenant isolation starting')\n //~ ORG A's user creates a row (identity/scope comes from their SESSION, not {data}).\n const created = await scenario.do(\n 'org A creates a company',\n 'createCompany',\n { name: 'Acme Inc' },\n { actor: actors.visitor },\n )\n //~ HALF 1 — the OWNER must see their own row.\n const own = await scenario.do(\n 'org A sees its own company',\n 'listCompanies',\n {},\n { actor: actors.visitor },\n )\n if (!own.companies.some((c) => c.id === created.company.id)) {\n throw new Error(\n 'org A created a company but does NOT see it in its own list — the tenant scope is too tight (hides your own data)',\n )\n }\n //~ HALF 2 — a DIFFERENT org's user must NOT see it.\n const foreign = await scenario.do(\n \"org B cannot see org A's company\",\n 'listCompanies',\n {},\n { actor: outsider },\n )\n if (foreign.companies.some((c) => c.id === created.company.id)) {\n throw new Error(\n \"org B can see org A's company — tenant isolation is BROKEN: a query is missing `where organizationId = session.activeOrganizationId`\",\n )\n }\n return { companyId: created.company.id }\n },\n})\n" }, { "name": "scenario-per-persona-view", "title": "Scenario — each colleague sees THEIR OWN app (browser, one scenario per persona)", "when": "PHASE 7.5/7.6, whenever TWO OR MORE personas share ONE app — a mechanic and a counter clerk, an accountant and HR and procurement. Colleagues share an app and differ by nav, landing screen and permitted actions, and NOTHING else proves they actually do: an app that renders one identical shell for all of them passes every other gate green. This runs in a REAL BROWSER as each person in turn, so it is the feature that speaks for \"logging in as the mechanic is not the same as logging in as the desk\". Needs each persona declared in definePersonas. Pull `--name scenario` first for the RPC-level shape.", "lang": "ts", "entity": "job", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-per-persona-view.ts", "content": "//~ name: scenario-per-persona-view\n//~ title: Scenario — each colleague sees THEIR OWN app (browser, one scenario per persona)\n//~ entity: job\n//~ when: PHASE 7.5/7.6, whenever TWO OR MORE personas share ONE app — a mechanic and a counter clerk, an accountant and HR and procurement. Colleagues share an app and differ by nav, landing screen and permitted actions, and NOTHING else proves they actually do: an app that renders one identical shell for all of them passes every other gate green. This runs in a REAL BROWSER as each person in turn, so it is the feature that speaks for \"logging in as the mechanic is not the same as logging in as the desk\". Needs each persona declared in definePersonas. Pull `--name scenario` first for the RPC-level shape.\n// ===== FILE: packages/functions/test/scenarios/mechanic-sees-their-work.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ THE NAMES BELOW ARE A WORKSHOP'S, AND THEY ARE NOT YOURS. `mechanic`, `desk`,\n//~ \"work queue\", \"front of house\" — every one is an EXAMPLE. Rename each to a persona\n//~ your `definePersonas` actually declares, in the export name, the title, the\n//~ description, the `actors.<name>` lookups and the thrown message. A merch build\n//~ pasted this verbatim and shipped `mechanicSeesTheirWorkScenario` and\n//~ `deskSeesTheFrontOfHouseScenario` into a t-shirt shop: both reference actors that\n//~ do not exist, so both fail on every run and neither failure means anything.\n//~\n//~ ONE SCENARIO PER PERSONA — never one scenario looping over actors. A failure has to\n//~ name WHO was let down (\"the mechanic never got a work queue\"), and a loop collapses\n//~ every person into one red line that says nothing about which of them is broken.\n//~\n//~ THE LADDER IS `when` → `then`, NOT `do`. `do` is the RPC path; the browser steps\n//~ (opensPage, restsOnPath, seesText, clicks, fills) are reached through when/then and are\n//~ typed against the declared steps in test/steps/. Only a `then` counts toward witness\n//~ coverage — a ladder with no `then` fails inspection outright (PKU680).\n//~\n//~ TAG IT `smoke`. That is what puts it in the browser sweep `pikku scenario browser --tag smoke` runs; an\n//~ untagged browser scenario is registered and never driven.\nexport const mechanicSeesTheirWorkScenario = pikkuScenario<void, { pathname: string }>({\n title: 'The mechanic lands on their own work queue',\n description: 'A mechanic signs in and gets the workshop floor, not the front desk',\n tags: ['scenario', 'smoke'],\n func: async (_services, _data, { scenario, actors }) => {\n if (!actors?.mechanic) {\n throw new Error(\n 'mechanicSeesTheirWorkScenario needs the mechanic actor — run via `pikku scenario run <environment>`',\n )\n }\n\n const landed = await scenario.when(\n 'the mechanic opens the app',\n 'opensPage',\n { path: '/app' },\n { actor: actors.mechanic },\n )\n\n //~ WHERE THEY LAND IS HALF THE CLAIM. Two roles sharing an app usually differ first by\n //~ home screen — the mechanic's is the job queue, the clerk's is the day's bookings. If\n //~ your app sends everyone to the same route, assert the same path in both scenarios and\n //~ let the seesText below carry the whole difference.\n await scenario.then(\n 'they come to rest on the workshop floor',\n 'restsOnPath',\n { path: '/app/jobs' },\n { actor: actors.mechanic },\n )\n\n //~ THE ASSERTION THAT DOES THE WORK: text only THIS person's screen offers. You do not\n //~ need a \"does not see\" step to prove differentiation — if both personas land on the\n //~ same undifferentiated shell, one of these two scenarios cannot find its text and goes\n //~ red. That is exactly the failure worth catching, and it names the person it failed.\n //~ Assert something ROLE-SHAPED (a heading, a nav item, an action only they get), never\n //~ a row of seeded data — data drifts, and a passing run then proves nothing.\n await scenario.then(\n 'their assigned repairs are on screen',\n 'seesText',\n { text: 'Assigned repairs' },\n { actor: actors.mechanic },\n )\n\n return { pathname: landed.pathname }\n },\n})\n\n// ===== FILE: packages/functions/test/scenarios/desk-sees-the-front-of-house.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ The SAME shape for the other colleague. Side by side these two are the whole point: same\n//~ app, same sign-in, two different screens. Copy this per persona sharing the app.\nexport const deskSeesTheFrontOfHouseScenario = pikkuScenario<void, { pathname: string }>({\n title: 'The counter clerk lands on the front desk',\n description: 'A clerk signs in and gets bookings and customers, not the repair queue',\n tags: ['scenario', 'smoke'],\n func: async (_services, _data, { scenario, actors }) => {\n if (!actors?.counter) {\n throw new Error(\n 'deskSeesTheFrontOfHouseScenario needs the counter actor — run via `pikku scenario run <environment>`',\n )\n }\n\n const landed = await scenario.when(\n 'the clerk opens the app',\n 'opensPage',\n { path: '/app' },\n { actor: actors.counter },\n )\n\n await scenario.then(\n 'they come to rest on the front desk',\n 'restsOnPath',\n { path: '/app/bookings' },\n { actor: actors.counter },\n )\n\n await scenario.then(\n \"today's bookings are on screen\",\n 'seesText',\n { text: \"Today's bookings\" },\n { actor: actors.counter },\n )\n\n return { pathname: landed.pathname }\n },\n})\n\n// ===== FILE: packages/functions/test/features/colleagues.feature.ts =====\nimport { pikkuFeature } from '#pikku/scenarios'\nimport { mechanicSeesTheirWorkScenario } from '../scenarios/mechanic-sees-their-work.scenario.js'\nimport { deskSeesTheFrontOfHouseScenario } from '../scenarios/desk-sees-the-front-of-house.scenario.js'\n\n//~ ONE FEATURE for \"the people who share this app\", and its description is the promise in\n//~ the owner's own words — not the test runner's. Green here means the shop owner can put\n//~ two different colleagues in front of this app and each gets their own job, which is the\n//~ thing they actually bought. Add a scenario here for EVERY persona in this app's\n//~ app — every persona whose `app` names this slug: a persona no scenario names is a person\n//~ whose experience is unproven, and the usual way that shows up in production is all of\n//~ them staring at the same screen.\nexport const colleaguesFeature = pikkuFeature({\n name: 'Everyone gets their own workshop',\n description: 'The mechanic and the counter clerk each sign in and land on their own work',\n tags: ['colleagues', 'smoke'],\n scenarios: [mechanicSeesTheirWorkScenario, deskSeesTheFrontOfHouseScenario],\n})\n" }, { "name": "scenario-permissions", "title": "Scenario — the permission-DENIED path (a member forbidden from an admin action)", "when": "PHASE 7.5/7.6, when the app has ROLES (admin vs member, owner vs viewer). A user being correctly BLOCKED is a real, must-test journey — it proves the `permissions` gate actually fires. Needs a second seeded actor (`member`); add that persona to definePersonas in packages/functions/src/personas.ts. Pull `--name scenario` first.", "lang": "ts", "entity": "deal", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-permissions.ts", "content": "//~ name: scenario-permissions\n//~ title: Scenario — the permission-DENIED path (a member forbidden from an admin action)\n//~ entity: deal\n//~ when: PHASE 7.5/7.6, when the app has ROLES (admin vs member, owner vs viewer). A user being correctly BLOCKED is a real, must-test journey — it proves the `permissions` gate actually fires. Needs a second seeded actor (`member`); add that persona to definePersonas in packages/functions/src/personas.ts. Pull `--name scenario` first.\n// ===== FILE: packages/functions/test/scenarios/member-cannot-delete.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ scenario.expectError SUCCEEDS only when the RPC THROWS — never treat the throw\n//~ as a failure; it returns the thrown message. The member persona is a data-only\n//~ seeded user (see PHASE 2.5). 3rd arg is `{ scenario, actors }` (NOT `workflow`).\nexport const memberCannotDeleteScenario = pikkuScenario<void, { denied: boolean }>({\n title: 'Member cannot delete (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n const member = actors?.member ?? actors?.visitor\n if (!member) throw new Error('needs run actors (member) — run via `pikku scenario run <env>`')\n logger.debug('permission-denied starting')\n //~ The admin creates something (actors.visitor is the dev admin), then the\n //~ member is DENIED deleting it. expectError returns the thrown message.\n const created = await scenario.do(\n 'admin creates a deal',\n 'createDeal',\n { title: 'Protected', stage: 'lead' },\n { actor: actors.visitor },\n )\n const message = await scenario.expectError(\n 'a member cannot delete it',\n 'deleteDeal',\n { id: created.deal.id },\n { actor: member },\n )\n logger.debug(`member correctly denied: ${message}`)\n return { denied: true }\n },\n})\n" }, { "name": "scenario-relational", "title": "Scenario — parent→child RELATIONSHIP link (thread a REAL created id, never a fake one)", "when": "PHASE 7.5/7.6, whenever entities REFERENCE each other (a contact belongs to a company, a task to a project, a line-item to an order). This is the pattern that KILLS the #1 scenario anti-pattern — poking `listContacts({ companyId: 'fake-id' })`. A journey THREADS the parent's real returned id into the child, then asserts the child shows UNDER the parent. Pull `--name scenario` first. This one is WRITTEN to `packages/functions/test/scenarios/company-contact-link.scenario.ts` and names TWO entities (company + contact) — rename both to yours in the file, since `--entity` only carries one.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-relational.ts", "content": "//~ name: scenario-relational\n//~ title: Scenario — parent→child RELATIONSHIP link (thread a REAL created id, never a fake one)\n//~ when: PHASE 7.5/7.6, whenever entities REFERENCE each other (a contact belongs to a company, a task to a project, a line-item to an order). This is the pattern that KILLS the #1 scenario anti-pattern — poking `listContacts({ companyId: 'fake-id' })`. A journey THREADS the parent's real returned id into the child, then asserts the child shows UNDER the parent. Pull `--name scenario` first. This one is WRITTEN to `packages/functions/test/scenarios/company-contact-link.scenario.ts` and names TWO entities (company + contact) — rename both to yours in the file, since `--entity` only carries one.\n// ===== FILE: packages/functions/test/scenarios/company-contact-link.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ The whole game: capture the id the PARENT create RETURNS, pass it to the CHILD\n//~ create, then assert the child shows up when you filter by that parent.\n//~\n//~ ❌ NEVER scenario.do('list contacts', 'listContacts', { companyId: 'fake-id' }, …)\n//~ ❌ NEVER a pile of one-call-per-endpoint steps with made-up ids/search terms —\n//~ that's a CRUD sweep, not a journey; it filters over nothing and\n//~ asserts nothing (nobody uses an app that way).\n//~ ✅ ALWAYS parent create → capture id → child create with that id → assert link.\n//~\n//~ NOTE: a domain foreign key like `companyId` (WHICH company this contact is in) is\n//~ fine in {data} — that is real relational data. It is NOT the same as an identity\n//~ id (userId/organizationId/tenantId), which must come from the SESSION, never {data}.\n//~ 3rd arg is `{ scenario, actors }` (NOT `workflow`).\nexport const companyContactLinkScenario = pikkuScenario<void, { companyId: string }>({\n title: 'Company → contact link (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.visitor) throw new Error('needs run actors — run via `pikku scenario run <env>`')\n logger.debug('company/contact link starting')\n //~ 1. create the PARENT — capture its REAL id.\n const company = await scenario.do(\n 'create a company',\n 'createCompany',\n { name: 'Acme Inc' },\n { actor: actors.visitor },\n )\n //~ 2. create the CHILD referencing the parent's REAL id (never a literal/fake id).\n const contact = await scenario.do(\n 'add a contact to it',\n 'createContact',\n { name: 'Grace Hopper', companyId: company.company.id },\n { actor: actors.visitor },\n )\n //~ 3. ASSERT the relationship resolves: filtering by the parent returns the child.\n const contacts = await scenario.do(\n 'the contact shows under the company',\n 'listContacts',\n { companyId: company.company.id },\n { actor: actors.visitor },\n )\n if (!contacts.contacts.some((c) => c.id === contact.contact.id)) {\n throw new Error(\n \"the contact was created with a companyId but does NOT show when listing that company's contacts — the FK or the join is wrong\",\n )\n }\n return { companyId: company.company.id }\n },\n})\n" }, { "name": "scenario-scheduled", "title": "Scenario — cover a CRON / async effect (runScheduledTask + expectEventually)", "when": "PHASE 7.5/7.6, when the app has a scheduled task, a queue worker, or any effect that lands ASYNCHRONOUSLY (a nightly digest, a reminder, a post-enqueue write). A scenario can FIRE the cron immediately and POLL for its effect — so these functions get covered without waiting for the schedule. Pull `--name scenario` first. This one is WRITTEN to `packages/functions/test/scenarios/daily-digest.scenario.ts` and names two things (the activity it creates and the digest it polls for) — rename both to yours in the file, since `--entity` only carries one.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-scheduled.ts", "content": "//~ name: scenario-scheduled\n//~ title: Scenario — cover a CRON / async effect (runScheduledTask + expectEventually)\n//~ when: PHASE 7.5/7.6, when the app has a scheduled task, a queue worker, or any effect that lands ASYNCHRONOUSLY (a nightly digest, a reminder, a post-enqueue write). A scenario can FIRE the cron immediately and POLL for its effect — so these functions get covered without waiting for the schedule. Pull `--name scenario` first. This one is WRITTEN to `packages/functions/test/scenarios/daily-digest.scenario.ts` and names two things (the activity it creates and the digest it polls for) — rename both to yours in the file, since `--entity` only carries one.\n// ===== FILE: packages/functions/test/scenarios/daily-digest.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ scenario.runScheduledTask fires a cron/scheduled task IMMEDIATELY so it gets\n//~ covered. scenario.expectEventually POLLS an RPC until the effect appears — use\n//~ it for anything that lands asynchronously. Queue jobs run when their enqueue RPC\n//~ is called in a step. Only a real scenario.sleep is genuinely exempt. 3rd arg is\n//~ `{ scenario, actors }` (NOT `workflow`).\nexport const dailyDigestScenario = pikkuScenario<void, { ok: boolean }>({\n title: 'Daily digest runs (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.visitor) throw new Error('needs run actors — run via `pikku scenario run <env>`')\n logger.debug('digest starting')\n await scenario.do(\n 'create some activity',\n 'createDeal',\n { title: 'For the digest', stage: 'lead' },\n { actor: actors.visitor },\n )\n //~ Fire the scheduled task now instead of waiting for its cron time.\n await scenario.runScheduledTask('sendDailyDigest')\n //~ Poll until the digest it produced is visible (up to `within` ms).\n await scenario.expectEventually(\n 'the digest was recorded',\n 'listDigests',\n {},\n (out: { digests: unknown[] }) => out.digests.length > 0,\n { actor: actors.visitor, within: 10_000 },\n )\n return { ok: true }\n },\n})\n" }, { "name": "scenario-transition", "title": "Scenario — a status/stage MOVE (board, pipeline, workflow state) asserted end-to-end", "when": "PHASE 7.5/7.6, when the app has anything that CHANGES STATE — a kanban move, a deal stage, an order status, an approve/reject. \"Can't move cards across the board\" is exactly this: the move RPC must exist AND the read must show the NEW state. Pull `--name scenario` first.", "lang": "ts", "entity": "deal", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/scenarios/scenario-transition.ts", "content": "//~ name: scenario-transition\n//~ title: Scenario — a status/stage MOVE (board, pipeline, workflow state) asserted end-to-end\n//~ entity: deal\n//~ when: PHASE 7.5/7.6, when the app has anything that CHANGES STATE — a kanban move, a deal stage, an order status, an approve/reject. \"Can't move cards across the board\" is exactly this: the move RPC must exist AND the read must show the NEW state. Pull `--name scenario` first.\n// ===== FILE: packages/functions/test/scenarios/deal-pipeline.scenario.ts =====\nimport { pikkuScenario } from '#pikku/scenarios'\n\n//~ create → fire the transition → read back → assert the NEW state landed.\n//~ 3rd arg is `{ scenario, actors }`. Name the transition RPC for your app\n//~ (moveDeal / updateDealStage / setStatus / approve …).\n//~\n//~ ⚠️ THIS PATTERN REQUIRES A CREATE for the entity you transition. Your actor\n//~ starts with an EMPTY world (seed rows belong to OTHER users — never reach for\n//~ them), so you must create the deal/invoice/claim here before moving it. If the\n//~ app has the transition but NO create the actor can call (e.g. `markInvoicePaid`\n//~ but no `createInvoice`), the function is UNCOVERABLE and that missing create is\n//~ a BUILD DEFECT — record it in one line for the build agent (name the missing\n//~ create RPC) and move on. Do NOT fake the entity, sign in as the seed owner, or\n//~ grep for another user's row to reach it.\nexport const dealPipelineScenario = pikkuScenario<void, { dealId: string }>({\n title: 'Deal pipeline move (scenario)',\n tags: ['scenario'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.visitor) throw new Error('needs run actors — run via `pikku scenario run <env>`')\n logger.debug('deal pipeline starting')\n const created = await scenario.do(\n 'create a deal',\n 'createDeal',\n { title: 'Acme renewal', stage: 'lead' },\n { actor: actors.visitor },\n )\n //~ The transition RPC (moveDeal / updateDealStage / setStatus — name it for your app).\n await scenario.do(\n 'move it to negotiation',\n 'moveDeal',\n { id: created.deal.id, stage: 'negotiation' },\n { actor: actors.visitor },\n )\n //~ ASSERT the move actually took — read it back and check the stage.\n const moved = await scenario.do(\n 'the deal is now in negotiation',\n 'getDeal',\n { id: created.deal.id },\n { actor: actors.visitor },\n )\n if (moved.deal.stage !== 'negotiation') {\n throw new Error(\n `moveDeal returned success but the deal is still in \"${moved.deal.stage}\" — the transition did not persist`,\n )\n }\n return { dealId: created.deal.id }\n },\n})\n" }, { "name": "scheduled-task", "title": "Scheduled/recurring task (wireScheduler + sessionless func)", "when": "Something must run on a schedule — cleanup, rollup, reminder sweep. For a MULTI-STEP process use {name: workflow} instead (a cron can start a workflow).", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/scheduled-task.ts", "content": "//~ name: scheduled-task\n//~ title: Scheduled/recurring task (wireScheduler + sessionless func)\n//~ when: Something must run on a schedule — cleanup, rollup, reminder sweep. For a MULTI-STEP process use {name: workflow} instead (a cron can start a workflow).\n//~ entity: todo\n//~ The two-file split below is not a style preference: co-locating the wireScheduler\n//~ call with the func makes pikku SKIP the task at runtime (\"Skipping scheduled task —\n//~ metadata not found\"), which fails silently in production. It used to be a paragraph\n//~ of prose the agent had to remember; writing both files is what retires the paragraph.\n\n// ===== FILE: packages/functions/src/functions/archive-stale-todos.function.ts =====\nimport { pikkuVoidFunc } from '#pikku/function'\n\n//~ A scheduled func MUST use pikkuVoidFunc — void→void, no input:/output:\n//~ schemas, no session (pikkuSessionlessFunc/pikkuFunc do NOT typecheck against\n//~ wireScheduler). Keep the body idempotent — the scheduler may retry.\nexport const archiveStaleTodos = pikkuVoidFunc({\n title: 'Archive stale todos',\n func: async ({ kysely, logger }) => {\n const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()\n const res = await kysely\n .updateTable('todo')\n .set({ updatedAt: new Date().toISOString() })\n .where('updatedAt', '<', cutoff)\n .executeTakeFirst()\n logger.info(`archiveStaleTodos: touched ${res.numUpdatedRows}`)\n },\n})\n\n// ===== FILE: packages/functions/src/wires/cron/archive-stale-todos.scheduler.ts =====\nimport { wireScheduler } from '#pikku/scheduler'\nimport { archiveStaleTodos } from '../../functions/archive-stale-todos.function.js'\n\n//~ Standard 5-field cron. Examples: '*/15 * * * *' every 15 min · '0 7 * * *'\n//~ daily 07:00 UTC · '0 9 * * 1' Mondays 09:00 UTC.\nwireScheduler({\n name: 'archiveStaleTodos',\n schedule: '0 3 * * *',\n func: archiveStaleTodos,\n})\n" }, { "name": "singleton-query", "title": "\"The caller's own X\" read, where not having one yet is normal", "when": "A screen reads ONE row belonging to the caller and its absence is a first-run state, not a 404 — the profile before it is filled in, the shop before it is claimed, the current draft.", "lang": "ts", "entity": "profile", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/data/singleton-query.ts", "content": "//~ name: singleton-query\n//~ title: \"The caller's own X\" read, where not having one yet is normal\n//~ when: A screen reads ONE row belonging to the caller and its absence is a first-run state, not a 404 — the profile before it is filled in, the shop before it is claimed, the current draft.\n//~ entity: profile\n\n// ===== FILE: packages/functions/src/functions/get-my-profile.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\nimport { ProfileZ } from '#pikku/db/zod.gen.js'\n\n//~ Different shape from the detail fetch in `list-query`: missing means the ONBOARDING\n//~ screen, so `.executeTakeFirstOrThrow` is wrong (a 404 for the expected first-run state).\n//~ THE TRAP: a func that returns `null` OR `undefined` is answered as **204 with NO\n//~ BODY** (pikku's http runner treats both as \"nothing to send\"), so the generated\n//~ client parses nothing and hands @tanstack/react-query `undefined` — which THROWS:\n//~ \"Query data cannot be undefined. Affected query key: [...]\". A blank screen and a\n//~ console error on every brand-new account, and no typecheck catches it because\n//~ `.executeTakeFirst()`'s `undefined` satisfies an optional/nullable output.\n//~ SO THE EMPTY ANSWER MUST STILL BE AN OBJECT: wrap the row in a named field and\n//~ make THAT field nullable. Always a 200 with a real body; the screen branches on\n//~ `data.profile === null` for its empty/onboarding state.\nexport const GetMyProfileInput = z.object({}) //~ no args — the session names the row\nexport const GetMyProfileOutput = z.object({\n //~ The WRAPPER is the point — `ProfileZ.pick({...}).nullable()` on its own would send\n //~ a bare null, which is the 204 above. Room to add siblings later, too.\n profile: ProfileZ.pick({ id: true, title: true }).nullable(),\n})\n\nexport const getMyProfile = pikkuFunc({\n expose: true,\n readonly: true,\n auth: true,\n description: \"Get the caller's own profile; `profile` is null until they make one.\",\n input: GetMyProfileInput,\n output: GetMyProfileOutput,\n func: async ({ kysely }, _input, { session }) => {\n const row = await kysely\n .selectFrom('profile')\n .select(['id', 'title'])\n .where('userId', '=', session!.userId)\n .executeTakeFirst()\n //~ `?? null` because `undefined` inside the object would be dropped by\n //~ JSON.stringify — the field must be PRESENT and null.\n return { profile: row ?? null }\n },\n})\n" }, { "name": "sse", "title": "Server-Sent Events — stream progress/updates one-way to the client", "when": "The app streams server→client over plain HTTP GET — a progress bar for a long job, a live-updating count/feed. For two-way realtime (chat, presence) use {name: channel} instead.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/sse.ts", "content": "//~ name: sse\n//~ title: Server-Sent Events — stream progress/updates one-way to the client\n//~ when: The app streams server→client over plain HTTP GET — a progress bar for a long job, a live-updating count/feed. For two-way realtime (chat, presence) use {name: channel} instead.\n//~ entity: todo\n\n// ===== FILE: packages/functions/src/functions/stream-todo-progress.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n\n//~ An SSE func is a normal pikkuFunc; the 3rd arg carries `channel` when the\n//~ request is an SSE stream. Emit interim frames with channel.send(...) and the\n//~ final value via return. The output zod types BOTH the frames and the return.\n//~ input/output are named consts — never inline at input:/output: (PKU489).\nexport const StreamTodoProgressInput = z.object({})\nexport const StreamTodoProgressOutput = z.object({\n status: z.enum(['started', 'processing', 'complete']),\n processed: z.number(),\n total: z.number(),\n})\n\nexport const streamTodoProgress = pikkuFunc({\n input: StreamTodoProgressInput,\n output: StreamTodoProgressOutput,\n func: async ({ kysely, logger }, _input, { session, channel }) => {\n const todos = await kysely\n .selectFrom('todo')\n .select(['id', 'title'])\n .where('userId', '=', session!.userId)\n .where('done', '=', 0)\n .execute()\n const total = todos.length\n logger.info(`streaming ${total} todos`)\n //~ channel is present only for the SSE request — guard it.\n if (channel) {\n channel.send({ status: 'started', processed: 0, total })\n for (let i = 0; i < total; i++) {\n channel.send({ status: 'processing', processed: i + 1, total })\n }\n }\n return { status: 'complete' as const, processed: total, total }\n },\n})\n\n// ===== FILE: packages/functions/src/wires/http/todo-progress.http.ts =====\nimport { wireHTTP } from '#pikku/http'\nimport { streamTodoProgress } from '../../functions/stream-todo-progress.function.js'\n\n//~ `sse: true` on a GET route turns it into a stream; the client reads it with an\n//~ EventSource / the generated SSE client.\nwireHTTP({\n method: 'get',\n route: '/todos/progress',\n func: streamTodoProgress,\n sse: true,\n tags: ['sse', 'realtime'],\n})\n" }, { "name": "stats-query", "title": "Dashboard stats RPC (counts/sums/group-by via kysely aggregates)", "when": "A page shows NUMBERS derived from rows — totals, counts, sums, a breakdown by status/category, an average. Any dashboard/overview/summary card.", "lang": "ts", "entity": "item", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/data/stats-query.ts", "content": "//~ name: stats-query\n//~ title: Dashboard stats RPC (counts/sums/group-by via kysely aggregates)\n//~ when: A page shows NUMBERS derived from rows — totals, counts, sums, a breakdown by status/category, an average. Any dashboard/overview/summary card.\n//~ entity: item\n\n// ===== FILE: packages/functions/src/functions/get-item-stats.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n//~ Stats OUTPUTS are COMPUTED, not DB columns — so you DECLARE the whole output\n//~ zod by hand (a plain z.object of numbers/labels), NOT from the generated row\n//~ zod. This is the opposite of list-query: nothing here is a table column.\n\n//~ No input needed for a whole-account summary; add filters (a date range, a\n//~ category) here if the screen has them.\nexport const GetItemStatsInput = z.object({})\n\n//~ Every field is a computed number/label. Keep it flat: the cards read\n//~ stats.total, stats.open, stats.byStatus[i].count directly.\nexport const GetItemStatsOutput = z.object({\n total: z.number(),\n open: z.number(),\n done: z.number(),\n totalValue: z.number(),\n byStatus: z.array(z.object({ status: z.string(), count: z.number() })),\n})\n\nexport const getItemStats = pikkuFunc({\n expose: true,\n readonly: true,\n auth: true,\n description: 'Summary counts for the signed-in user’s items.',\n input: GetItemStatsInput,\n output: GetItemStatsOutput,\n func: async ({ kysely }, _input, { session }) => {\n //~ PATTERN 1 — MANY aggregates in ONE round-trip. Select several eb.fn\n //~ expressions from the same table; each becomes one column on a single row.\n //~ `count`/`countAll` count rows; `sum` adds a column. WRAP sums in\n //~ coalesce(..., 0) so an empty table returns 0, not null. `filterWhere`\n //~ gives a conditional count (open vs done) WITHOUT a second query.\n //~ Alias every aggregate with `.as('name')` — that name is the result key.\n const totals = await kysely\n .selectFrom('item')\n .where('userId', '=', session!.userId) //~ ALWAYS scope to the session — never global\n .select((eb) => [\n eb.fn.countAll().as('total'),\n eb.fn.count('id').filterWhere('done', '=', 0).as('open'),\n eb.fn.count('id').filterWhere('done', '=', 1).as('done'),\n eb.fn.coalesce(eb.fn.sum('price'), eb.val(0)).as('totalValue'),\n ])\n .executeTakeFirstOrThrow()\n\n //~ PATTERN 2 — a BREAKDOWN (one row per group) = groupBy + a count. Use this\n //~ for \"by status/category/day\" lists. `orderBy` a computed alias with `sql`.\n const byStatus = await kysely\n .selectFrom('item')\n .where('userId', '=', session!.userId)\n .select((eb) => ['status', eb.fn.countAll().as('count')])\n .groupBy('status')\n .execute()\n\n //~ Aggregate results can arrive as strings/bigint depending on driver — coerce\n //~ with Number() so they match the z.number() output (don't return raw).\n return {\n total: Number(totals.total),\n open: Number(totals.open),\n done: Number(totals.done),\n totalValue: Number(totals.totalValue),\n byStatus: byStatus.map((r) => ({ status: String(r.status), count: Number(r.count) })),\n }\n },\n})\n" }, { "name": "transactional-email", "title": "Themed transactional email (template + locale + send call)", "when": "The app sends an email — welcome, confirmation, digest, notification. Pass the EMAIL as the entity (`--entity order-confirmation`) and it writes the Handlebars template plus the sending function, both already named for it. TWO THINGS ARE STILL YOURS. (1) Add the matching key to `emails/locales/en.json` — camelCase of the template name, with `subject` and `preview` REQUIRED, plus every `{{t.<key>.*}}` the template reads; that file already exists so the scaffold will not touch it. (2) Run pikku-verify afterwards — ANY change under `emails/` has to regenerate the typed template map before the `name:` you pass to `emailService.send` type-checks. Theme every colour with `{{theme.colors.*}}` (never a literal), and add a CTA button ONLY if you already hold a real ABSOLUTE url to point it at — emails cannot use relative links, there is no ambient app URL to hunt for, and a heading + body email is complete without one.", "lang": "ts", "entity": "welcomeEmail", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/transactional-email.ts", "content": "//~ name: transactional-email\n//~ title: Themed transactional email (template + locale + send call)\n//~ when: The app sends an email — welcome, confirmation, digest, notification. Pass the EMAIL as the entity (`--entity order-confirmation`) and it writes the Handlebars template plus the sending function, both already named for it. TWO THINGS ARE STILL YOURS. (1) Add the matching key to `emails/locales/en.json` — camelCase of the template name, with `subject` and `preview` REQUIRED, plus every `{{t.<key>.*}}` the template reads; that file already exists so the scaffold will not touch it. (2) Run pikku-verify afterwards — ANY change under `emails/` has to regenerate the typed template map before the `name:` you pass to `emailService.send` type-checks. Theme every colour with `{{theme.colors.*}}` (never a literal), and add a CTA button ONLY if you already hold a real ABSOLUTE url to point it at — emails cannot use relative links, there is no ambient app URL to hunt for, and a heading + body email is complete without one.\n//~ entity: welcomeEmail\n\n// ===== FILE: emails/templates/welcome-email.html =====\n<h1 style=\"margin:0 0 16px;color:{{theme.colors.text}};font-size:28px;line-height:1.1;\">\n {{t.welcomeEmail.heading}}\n</h1>\n<p style=\"margin:0 0 24px;color:{{theme.colors.muted}};font-size:16px;line-height:1.6;\">\n {{t.welcomeEmail.body}}\n</p>\n{{> footer}}\n\n\n// ===== FILE: packages/functions/src/functions/send-welcome-email.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ input/output are named module-level consts — never inline at the input:/output:\n//~ site (PKU489). `emailService` is always wired; there is nothing to register.\nexport const SendWelcomeEmailInput = z.object({\n email: z.string().email(),\n firstName: z.string(),\n})\nexport const SendWelcomeEmailOutput = z.object({ ok: z.boolean() })\n\nexport const sendWelcomeEmail = pikkuSessionlessFunc({\n expose: true,\n description: 'Send the welcome-email email.',\n input: SendWelcomeEmailInput,\n output: SendWelcomeEmailOutput,\n func: async ({ emailService }, input) => {\n await emailService.send({\n to: input.email,\n //~ `name` is typed against emails/templates/ — a red squiggle here means\n //~ pikku-verify has not regenerated the template map yet.\n template: {\n name: 'welcome-email',\n data: { firstName: input.firstName },\n },\n })\n return { ok: true }\n },\n})\n\n//~ Many recipients? Promise.all the sends inside ONE function body — see\n//~ {name: workflow} for the digest fan-out pattern.\n" }, { "name": "trigger", "title": "Trigger — subscribe to an external event source, invoke an RPC per event", "when": "An OUTSIDE source should drive the app — a webhook feed, a poll of a third-party API, a message bus — turning each event into an RPC call. For a fixed clock schedule use {name: scheduled-task}; for in-app realtime use {name: channel}.", "lang": "ts", "entity": "testEvent", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/trigger.ts", "content": "//~ name: trigger\n//~ title: Trigger — subscribe to an external event source, invoke an RPC per event\n//~ when: An OUTSIDE source should drive the app — a webhook feed, a poll of a third-party API, a message bus — turning each event into an RPC call. For a fixed clock schedule use {name: scheduled-task}; for in-app realtime use {name: channel}.\n//~ entity: testEvent\n\n// ===== FILE: packages/functions/src/functions/test-event-source.function.ts =====\nimport { pikkuTriggerFunc } from '#pikku/trigger'\n\n//~ The SOURCE sets up the subscription and returns a TEARDOWN fn. Generics =\n//~ <SetupInput, InvokePayload>. Call trigger.invoke(payload) whenever an event\n//~ arrives; the payload becomes the TARGET func's input.\nexport const testEventSource = pikkuTriggerFunc<{ eventName: string }, { payload: string }>(\n async ({ logger }, { eventName }, { trigger }) => {\n logger.info(`trigger source up for ${eventName}`)\n //~ Real sources: open a webhook subscription / poll an API. Example = a poll.\n const interval = setInterval(\n () => trigger.invoke({ payload: `event from ${eventName}` }),\n 1_000,\n )\n return () => {\n clearInterval(interval)\n logger.info(`trigger source down for ${eventName}`)\n }\n },\n)\n\n// ===== FILE: packages/functions/src/functions/on-test-event.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ The TARGET RPC invoked per event — a normal sessionless func doing the work.\n//~ input/output are named consts — never inline at input:/output: (PKU489).\nexport const OnTestEventInput = z.object({ payload: z.string() })\nexport const OnTestEventOutput = z.object({ ok: z.boolean() })\n\nexport const onTestEvent = pikkuSessionlessFunc({\n input: OnTestEventInput,\n output: OnTestEventOutput,\n func: async ({ logger }, { payload }) => {\n logger.info(`handling event: ${payload}`)\n return { ok: true }\n },\n})\n\n// ===== FILE: packages/functions/src/wires/trigger/test-event.trigger.ts =====\nimport { wireTrigger, wireTriggerSource } from '#pikku/trigger'\nimport { onTestEvent } from '../../functions/on-test-event.function.js'\nimport { testEventSource } from '../../functions/test-event-source.function.js'\n\n//~ Matching literal names. wireTrigger binds the TARGET; wireTriggerSource binds\n//~ the SOURCE + its setup input.\nwireTrigger({ name: 'test-event', func: onTestEvent })\nwireTriggerSource({ name: 'test-event', func: testEventSource, input: { eventName: 'test-event' } })\n" }, { "name": "ui-agent-chat", "title": "Talk to a declared agent from a screen with usePikkuAgent", "when": "A screen needs a conversation rather than a form — asking about the data in plain language, or letting someone describe what they want done. Only reach for this once an agent is declared on the backend with the tools it is allowed to use; an agent with no tools is a chatbot that cannot help.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": [], "steps": "`usePikkuAgent` comes from `@pikku/react` itself, not from the generated file — it is\ntransport, and the agent's name is the only thing that varies. The `threadId` is what\nmakes a conversation a conversation: reuse it and the agent remembers, generate a fresh\none and it starts over. Keep it with whatever the conversation is ABOUT, so reopening\nthe job reopens the thread.", "source": "examples/field-service/apps/app/src/examples/ui-agent-chat.tsx", "content": "//~ name: ui-agent-chat\n//~ title: Talk to a declared agent from a screen with usePikkuAgent\n//~ when: A screen needs a conversation rather than a form — asking about the data in plain language, or letting someone describe what they want done. Only reach for this once an agent is declared on the backend with the tools it is allowed to use; an agent with no tools is a chatbot that cannot help.\n//~ entity: job\n//~ lang: tsx\n\n//~ steps:\n//~ `usePikkuAgent` comes from `@pikku/react` itself, not from the generated file — it is\n//~ transport, and the agent's name is the only thing that varies. The `threadId` is what\n//~ makes a conversation a conversation: reuse it and the agent remembers, generate a fresh\n//~ one and it starts over. Keep it with whatever the conversation is ABOUT, so reopening\n//~ the job reopens the thread.\n// ===== FILE: src/components/dispatch-chat.tsx =====\nimport { useState } from 'react'\nimport { usePikkuAgent } from '@pikku/react'\n\nexport const DispatchChat = ({ threadId }: { threadId: string }) => {\n const agent = usePikkuAgent('dispatch-assistant')\n const [message, setMessage] = useState('')\n const [reply, setReply] = useState<string | null>(null)\n const [pending, setPending] = useState(false)\n const [error, setError] = useState<Error | null>(null)\n\n const ask = async () => {\n setPending(true)\n setError(null)\n try {\n const outcome = await agent.run({\n message,\n threadId,\n //~ Who is asking. The agent runs with the caller's own session, so the\n //~ tools it calls are gated exactly as they are for a human — an agent\n //~ is not a way around a permission.\n resourceId: threadId,\n })\n //~ The reply is on `result`, not on a `text` field. An empty transcript\n //~ with a populated `result` is the normal shape, not a failure.\n setReply(String(outcome.result ?? ''))\n } catch (cause) {\n setError(cause instanceof Error ? cause : new Error(String(cause)))\n } finally {\n setPending(false)\n }\n }\n\n return (\n <section>\n <input\n value={message}\n onChange={(event) => setMessage(event.target.value)}\n placeholder=\"Who is free this afternoon?\"\n />\n <button onClick={ask} disabled={pending || message.length === 0}>\n {pending ? 'Thinking…' : 'Ask'}\n </button>\n {reply ? <p>{reply}</p> : null}\n {error ? <p role=\"alert\">{error.message}</p> : null}\n </section>\n )\n}\n" }, { "name": "ui-dev-sign-in", "title": "Offer one-click sign-in as a declared persona while developing", "when": "The app has a sign-in wall and every screen behind it is unreachable until somebody types a password — which is every new project, on the first day. Use this to get a working session in one click during development, and to let browser scenarios in as the persona they are written for. It renders nothing in production.", "lang": "tsx", "entity": "technician", "deferUntil": "", "requires": [], "steps": "The hook returns an empty actor list unless the host supplied BOTH the personas and\ntheir credentials, so a production bundle renders nothing without you testing for it.\nGate the env reads on the dev flag anyway — that is what keeps a credential out of the\nproduction bundle in the first place, and this is the second line of defence, not the\nfirst.", "source": "examples/field-service/apps/app/src/examples/ui-dev-sign-in.tsx", "content": "//~ name: ui-dev-sign-in\n//~ title: Offer one-click sign-in as a declared persona while developing\n//~ when: The app has a sign-in wall and every screen behind it is unreachable until somebody types a password — which is every new project, on the first day. Use this to get a working session in one click during development, and to let browser scenarios in as the persona they are written for. It renders nothing in production.\n//~ entity: technician\n//~ lang: tsx\n\n//~ steps:\n//~ The hook returns an empty actor list unless the host supplied BOTH the personas and\n//~ their credentials, so a production bundle renders nothing without you testing for it.\n//~ Gate the env reads on the dev flag anyway — that is what keeps a credential out of the\n//~ production bundle in the first place, and this is the second line of defence, not the\n//~ first.\n// ===== FILE: src/components/dev-sign-in.tsx =====\nimport { useDevActors } from '@pikku/react'\n\nexport const DevSignIn = () => {\n const { actors, signInAs, pendingEmail, error } = useDevActors({\n //~ Spelled for the bundler you are on. This package deliberately does not\n //~ read env itself: `import.meta.env` under Vite, `process.env.NEXT_PUBLIC_*`\n //~ under Next, and a package that guesses gets it wrong for half its users.\n actors: import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined,\n secrets: import.meta.env.DEV\n ? import.meta.env.VITE_DEV_ACTOR_SECRETS\n : undefined,\n apiUrl: '/api',\n onSignedIn: () => {\n //~ A full reload rather than a router push. Everything on the page was\n //~ fetched as nobody, so the cheapest correct thing is to start again as\n //~ somebody.\n window.location.assign('/')\n },\n })\n\n if (actors.length === 0) return null\n\n return (\n <section>\n <h2>Sign in as…</h2>\n <ul>\n {actors.map((actor) => (\n <li key={actor.email}>\n <button\n onClick={() => signInAs(actor.email)}\n disabled={pendingEmail !== null}\n >\n {actor.name} — {actor.jobTitle}\n </button>\n </li>\n ))}\n </ul>\n {error ? <p role=\"alert\">{error.message}</p> : null}\n </section>\n )\n}\n" }, { "name": "ui-infinite-list", "title": "Page through a long list with the generated usePikkuInfiniteQuery", "when": "A list is long enough that one request is the wrong shape — a job history, an audit trail, a feed. Only reach for this once the backend RPC actually returns a cursor; a screen that loads everything and slices it in the browser is a different bug.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": [], "steps": "`usePikkuInfiniteQuery` is generated ONLY for RPCs whose output schema carries a\n`nextCursor` field. If the import below does not resolve, the backend function is\nthe thing to change: give its output `nextCursor: z.string().nullable()`, return the\ncursor of the last row, and accept a `cursor` input. Nothing on the frontend can\nconjure the hook into existence.", "source": "examples/field-service/apps/app/src/examples/ui-infinite-list.tsx", "content": "//~ name: ui-infinite-list\n//~ title: Page through a long list with the generated usePikkuInfiniteQuery\n//~ when: A list is long enough that one request is the wrong shape — a job history, an audit trail, a feed. Only reach for this once the backend RPC actually returns a cursor; a screen that loads everything and slices it in the browser is a different bug.\n//~ entity: job\n//~ lang: tsx\n\n//~ steps:\n//~ `usePikkuInfiniteQuery` is generated ONLY for RPCs whose output schema carries a\n//~ `nextCursor` field. If the import below does not resolve, the backend function is\n//~ the thing to change: give its output `nextCursor: z.string().nullable()`, return the\n//~ cursor of the last row, and accept a `cursor` input. Nothing on the frontend can\n//~ conjure the hook into existence.\n// ===== FILE: src/components/job-history.tsx =====\nimport { usePikkuInfiniteQuery } from '../pikku/api.gen'\n\nexport const JobHistory = () => {\n const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =\n usePikkuInfiniteQuery('listJobs', { limit: 20 })\n\n if (isPending) return <p>Loading…</p>\n\n return (\n <>\n <ul>\n {/*~ `data.pages` is one entry per request. Flattening here rather than in the\n backend keeps each page independently cacheable. */}\n {data?.pages.flatMap((page) =>\n page.jobs.map((job) => (\n <li key={job.jobId}>\n {job.title} — {job.status}\n </li>\n ))\n )}\n </ul>\n {hasNextPage ? (\n <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>\n {isFetchingNextPage ? 'Loading…' : 'Load more'}\n </button>\n ) : null}\n </>\n )\n}\n" }, { "name": "ui-list", "title": "Read a list into a screen with the GENERATED usePikkuQuery hook", "when": "A screen has to show rows the backend already returns from an exposed RPC. This is the default read path for every list, table and detail screen — reach for it before useEffect + fetch, before a hand-written client, and before anything that types the response by hand.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": [], "steps": "`usePikkuQuery` is NOT exported from @pikku/react. It is GENERATED per project by\nthe CLI from your own RPC map, into the file named by `clientFiles.reactQueryFile`\nin pikku.config.json. If the import below does not resolve, that key is unset — set\nit and re-run `pikku all`. That is also why the name and the argument shape are\ntype-checked: `'listJobs'` is a key of YOUR FlattenedRPCMap, and passing an input\nthe function does not accept is a compile error, not a 400 at runtime.", "source": "examples/field-service/apps/app/src/examples/ui-list.tsx", "content": "//~ name: ui-list\n//~ title: Read a list into a screen with the GENERATED usePikkuQuery hook\n//~ when: A screen has to show rows the backend already returns from an exposed RPC. This is the default read path for every list, table and detail screen — reach for it before useEffect + fetch, before a hand-written client, and before anything that types the response by hand.\n//~ entity: job\n//~ lang: tsx\n\n//~ steps:\n//~ `usePikkuQuery` is NOT exported from @pikku/react. It is GENERATED per project by\n//~ the CLI from your own RPC map, into the file named by `clientFiles.reactQueryFile`\n//~ in pikku.config.json. If the import below does not resolve, that key is unset — set\n//~ it and re-run `pikku all`. That is also why the name and the argument shape are\n//~ type-checked: `'listJobs'` is a key of YOUR FlattenedRPCMap, and passing an input\n//~ the function does not accept is a compile error, not a 400 at runtime.\n// ===== FILE: src/components/job-list.tsx =====\nimport { usePikkuQuery } from '../pikku/api.gen'\n\n//~ The row type comes OUT of the generated map — never write a parallel interface for\n//~ it. A hand-written `type Job = { ... }` compiles happily while the backend schema\n//~ moves on, and the drift only shows as undefined at runtime.\nexport const JobList = () => {\n //~ First argument: the RPC name. Second: its input, which is also the query key, so\n //~ changing a filter refetches with no extra wiring. Third (optional): react-query\n //~ options, minus queryKey/queryFn which the hook owns.\n const { data, isPending, error } = usePikkuQuery(\n 'listJobs',\n { status: 'scheduled' },\n { staleTime: 10_000 }\n )\n\n //~ Three states, always. A screen that renders only the happy path shows an empty\n //~ box for both \"still loading\" and \"the server said no\", which is the bug report\n //~ that reads \"it just doesn't work sometimes\".\n if (isPending) return <p>Loading jobs…</p>\n if (error) return <p role=\"alert\">Could not load jobs: {error.message}</p>\n\n return (\n <ul>\n {data.jobs.map((job) => (\n <li key={job.jobId}>\n <strong>{job.title}</strong> — {job.status}\n {job.technicianName ? ` · ${job.technicianName}` : ' · unassigned'}\n </li>\n ))}\n </ul>\n )\n}\n" }, { "name": "ui-live-board", "title": "Let the server push, and refresh the query instead of duplicating its state", "when": "A screen several people watch at once — a dispatch board, an order queue, a live status wall. Use it when a stale screen causes a wrong decision. A dashboard nobody stares at is better served by a refetch interval, which costs nothing to maintain.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": ["ui-list"], "steps": "The event invalidates; it does not carry the row. That is the whole trick. A push that\npatches the cache directly needs the payload to match the query's shape forever, and it\ndrifts the first time the backend adds a field — whereas invalidating makes the server\nthe single source of truth and costs one cheap refetch. Topics are plain strings unless\nyou set `clientFiles.realtimeEventHubTopicsImport`, which makes subscribe/unsubscribe\nfully typed.", "source": "examples/field-service/apps/app/src/examples/ui-live-board.tsx", "content": "//~ name: ui-live-board\n//~ title: Let the server push, and refresh the query instead of duplicating its state\n//~ when: A screen several people watch at once — a dispatch board, an order queue, a live status wall. Use it when a stale screen causes a wrong decision. A dashboard nobody stares at is better served by a refetch interval, which costs nothing to maintain.\n//~ entity: job\n//~ requires: ui-list\n//~ lang: tsx\n\n//~ steps:\n//~ The event invalidates; it does not carry the row. That is the whole trick. A push that\n//~ patches the cache directly needs the payload to match the query's shape forever, and it\n//~ drifts the first time the backend adds a field — whereas invalidating makes the server\n//~ the single source of truth and costs one cheap refetch. Topics are plain strings unless\n//~ you set `clientFiles.realtimeEventHubTopicsImport`, which makes subscribe/unsubscribe\n//~ fully typed.\n// ===== FILE: src/components/live-board.tsx =====\nimport { useEffect } from 'react'\nimport { usePikkuRealtime } from '@pikku/react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport type { PikkuRealtime } from '#pikku/realtime.gen.js'\nimport { usePikkuQuery } from '../pikku/api.gen'\n\nexport const LiveBoard = ({ companyId }: { companyId: string }) => {\n const realtime = usePikkuRealtime<PikkuRealtime>()\n const queryClient = useQueryClient()\n const jobs = usePikkuQuery('listJobs', {})\n\n useEffect(() => {\n //~ `subscribe` returns its own unsubscribe, so the effect's cleanup is the\n //~ return value verbatim. Forgetting it leaks a handler per mount, which is\n //~ how a board ends up refetching six times per event.\n return realtime.subscribe(`board:${companyId}`, () => {\n queryClient.invalidateQueries({ queryKey: ['listJobs'] })\n })\n }, [realtime, queryClient, companyId])\n\n return (\n <ul>\n {jobs.data?.jobs.map((job) => (\n <li key={job.jobId}>\n {job.title} — {job.status}\n </li>\n ))}\n </ul>\n )\n}\n" }, { "name": "ui-mutation", "title": "Write with usePikkuMutation, then invalidate the list so the screen catches up", "when": "A form or a button changes something on the server and a list elsewhere on the page has to reflect it. This is the second half of every CRUD screen — pair it with list-query. Use it instead of refetching by hand, re-navigating, or reloading the page after a save.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": ["ui-list"], "steps": "The invalidation key is the FIRST argument of the query you want to refresh — the\ngenerated `usePikkuQuery` keys on `[name, input]`, so `['listJobs']` matches every\nvariation of that query regardless of its filters. Passing the input too narrows it\nto one filter and leaves the others stale, which is the bug where the row appears on\none tab and not the next.", "source": "examples/field-service/apps/app/src/examples/ui-mutation.tsx", "content": "//~ name: ui-mutation\n//~ title: Write with usePikkuMutation, then invalidate the list so the screen catches up\n//~ when: A form or a button changes something on the server and a list elsewhere on the page has to reflect it. This is the second half of every CRUD screen — pair it with list-query. Use it instead of refetching by hand, re-navigating, or reloading the page after a save.\n//~ entity: job\n//~ requires: ui-list\n//~ lang: tsx\n\n//~ steps:\n//~ The invalidation key is the FIRST argument of the query you want to refresh — the\n//~ generated `usePikkuQuery` keys on `[name, input]`, so `['listJobs']` matches every\n//~ variation of that query regardless of its filters. Passing the input too narrows it\n//~ to one filter and leaves the others stale, which is the bug where the row appears on\n//~ one tab and not the next.\n// ===== FILE: src/components/raise-job-form.tsx =====\nimport { useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { usePikkuMutation, usePikkuQuery } from '../pikku/api.gen'\n\nexport const RaiseJobForm = () => {\n const queryClient = useQueryClient()\n const [title, setTitle] = useState('')\n const [customerId, setCustomerId] = useState('')\n\n //~ `null`, not `{}`. An RPC that takes no input is typed `null` in the\n //~ generated map, and that is the argument the hook wants.\n const customers = usePikkuQuery('listCustomers', null)\n\n //~ One argument: the RPC name. The input type of `mutate` comes from the same\n //~ generated map, so the object below is checked against the function's zod schema\n //~ at compile time.\n const raise = usePikkuMutation('raiseJob', {\n //~ onSuccess is where the cache catches up. Doing it here rather than in the submit\n //~ handler means it also runs for a retry, and never runs for a failed write.\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: ['listJobs'] })\n setTitle('')\n },\n })\n\n return (\n <form\n onSubmit={(event) => {\n event.preventDefault()\n //~ Fire and forget: the mutation object below carries the pending and error\n //~ state, so there is nothing to await and nothing to catch here.\n raise.mutate({ customerId, title })\n }}\n >\n <label>\n Site\n <select\n value={customerId}\n onChange={(event) => setCustomerId(event.target.value)}\n >\n <option value=\"\">Choose a site…</option>\n {customers.data?.customers.map((customer) => (\n <option key={customer.customerId} value={customer.customerId}>\n {customer.name}\n </option>\n ))}\n </select>\n </label>\n <label>\n What is wrong\n <input\n value={title}\n onChange={(event) => setTitle(event.target.value)}\n />\n </label>\n {/*~ `isPending` disables the button, so a double click cannot raise two jobs. */}\n <button type=\"submit\" disabled={raise.isPending || !customerId || !title}>\n {raise.isPending ? 'Raising…' : 'Raise job'}\n </button>\n {/*~ The error renders next to the control that caused it. A mutation whose error\n is never read is a save that silently did nothing. */}\n {raise.error ? <p role=\"alert\">{raise.error.message}</p> : null}\n </form>\n )\n}\n" }, { "name": "ui-provider", "title": "Wire the generated client into React once, at the root", "when": "The first screen in a new frontend, before any hook can be used. Every other React recipe assumes this is already in place — a component under neither provider throws at render rather than failing the build, which is a confusing way to find out.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": [], "steps": "Two providers, and the order matters. `PikkuProvider` carries the transport; react-query\ncarries the cache; the generated hooks reach for both. Build the client OUTSIDE the\ncomponent — one built inside the body is rebuilt on every render, and a fresh\n`QueryClient` on every render is a cache that never hits.", "source": "examples/field-service/apps/app/src/examples/ui-provider.tsx", "content": "//~ name: ui-provider\n//~ title: Wire the generated client into React once, at the root\n//~ when: The first screen in a new frontend, before any hook can be used. Every other React recipe assumes this is already in place — a component under neither provider throws at render rather than failing the build, which is a confusing way to find out.\n//~ entity: job\n//~ lang: tsx\n\n//~ steps:\n//~ Two providers, and the order matters. `PikkuProvider` carries the transport; react-query\n//~ carries the cache; the generated hooks reach for both. Build the client OUTSIDE the\n//~ component — one built inside the body is rebuilt on every render, and a fresh\n//~ `QueryClient` on every render is a cache that never hits.\n// ===== FILE: src/main.tsx =====\nimport { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { PikkuProvider, createPikku } from '@pikku/react'\nimport { PikkuFetch } from '#pikku/pikku-fetch.gen.js'\nimport { PikkuRPC } from '#pikku/pikku-rpc.gen.js'\n\nconst pikku = createPikku(PikkuFetch, PikkuRPC, {\n //~ `/api` in every environment. A deployed worker serves the API on the same\n //~ origin under this prefix, and the dev server proxies it there, so the app\n //~ never has to know its own hostname at build time.\n serverUrl: '/api',\n})\n\nconst queryClient = new QueryClient()\n\ncreateRoot(document.getElementById('root')!).render(\n <StrictMode>\n <PikkuProvider pikku={pikku}>\n <QueryClientProvider client={queryClient}>\n <p>Your app goes here.</p>\n </QueryClientProvider>\n </PikkuProvider>\n </StrictMode>\n)\n" }, { "name": "ui-typed-form", "title": "Type a form from the generated input type and let the server own validation", "when": "Any form that writes through an RPC. Reach for it instead of hand-writing an interface for the form's fields, and instead of mirroring the backend's validation rules in the browser where they will drift the first time someone changes a schema.", "lang": "tsx", "entity": "job", "deferUntil": "", "requires": ["ui-mutation"], "steps": "The input type comes out of the generated RPC map, which is built from the function's\nown zod schema. So the schema IS the form's type: add a required field on the backend\nand this file stops compiling, which is the moment you want to hear about it. Client\nvalidation is then a convenience for the shape of the control — `required`, a number\ninput, a select — not a second copy of the rules. The authoritative refusal comes back\non `mutation.error` and renders next to the field.", "source": "examples/field-service/apps/app/src/examples/ui-typed-form.tsx", "content": "//~ name: ui-typed-form\n//~ title: Type a form from the generated input type and let the server own validation\n//~ when: Any form that writes through an RPC. Reach for it instead of hand-writing an interface for the form's fields, and instead of mirroring the backend's validation rules in the browser where they will drift the first time someone changes a schema.\n//~ entity: job\n//~ requires: ui-mutation\n//~ lang: tsx\n\n//~ steps:\n//~ The input type comes out of the generated RPC map, which is built from the function's\n//~ own zod schema. So the schema IS the form's type: add a required field on the backend\n//~ and this file stops compiling, which is the moment you want to hear about it. Client\n//~ validation is then a convenience for the shape of the control — `required`, a number\n//~ input, a select — not a second copy of the rules. The authoritative refusal comes back\n//~ on `mutation.error` and renders next to the field.\n// ===== FILE: src/components/quote-form.tsx =====\nimport { useState } from 'react'\nimport type { DraftQuoteInput } from '#pikku/rpc/pikku-rpc-wirings-map.gen.d.js'\nimport { usePikkuMutation } from '../pikku/api.gen'\n\nexport const QuoteForm = ({ jobId }: { jobId: string }) => {\n //~ One piece of state for the whole payload, typed by the wire contract. A\n //~ per-field `useState` drifts from the schema silently; this cannot.\n const [draft, setDraft] = useState<DraftQuoteInput>({\n jobId,\n amountCents: 0,\n summary: '',\n })\n\n const draftQuote = usePikkuMutation('draftQuote')\n\n return (\n <form\n onSubmit={(event) => {\n event.preventDefault()\n draftQuote.mutate(draft)\n }}\n >\n <label>\n Summary\n <input\n value={draft.summary}\n onChange={(event) =>\n setDraft({ ...draft, summary: event.target.value })\n }\n />\n </label>\n <label>\n Amount\n <input\n type=\"number\"\n //~ Money in the smallest unit, because that is what the schema says.\n //~ Converting at the edge of the form keeps every layer below it in\n //~ integers.\n value={draft.amountCents / 100}\n onChange={(event) =>\n setDraft({\n ...draft,\n amountCents: Math.round(Number(event.target.value) * 100),\n })\n }\n />\n </label>\n <button type=\"submit\" disabled={draftQuote.isPending}>\n {draftQuote.isPending ? 'Saving…' : 'Draft quote'}\n </button>\n {draftQuote.error ? <p role=\"alert\">{draftQuote.error.message}</p> : null}\n {draftQuote.data?.needsApproval ? (\n <p>Over the threshold — a dispatcher has to sign this off.</p>\n ) : null}\n </form>\n )\n}\n" }, { "name": "ui-workflow-gate", "title": "Start a durable approval and poll it with the generated workflow hooks", "when": "A screen kicks off something that waits for a person or an external system — a quote that needs sign-off, a refund above a threshold, an onboarding that pauses on a document. Use this when the wait is durable and measured in hours or days. A request that merely takes ten seconds is a mutation, not a workflow.", "lang": "tsx", "entity": "quote", "deferUntil": "", "requires": [], "steps": "`useStartWorkflow` and `useWorkflowStatus` are generated only when the project has at\nleast one workflow, and they are typed from the workflow map rather than the RPC map.\nThe run id is the whole handle: hold it, and the browser can close, the tab can reload,\nthe server can redeploy, and the gate is still there when someone comes back for it.\nWhich is why it belongs in a URL or a row, not only in React state.", "source": "examples/field-service/apps/app/src/examples/ui-workflow-gate.tsx", "content": "//~ name: ui-workflow-gate\n//~ title: Start a durable approval and poll it with the generated workflow hooks\n//~ when: A screen kicks off something that waits for a person or an external system — a quote that needs sign-off, a refund above a threshold, an onboarding that pauses on a document. Use this when the wait is durable and measured in hours or days. A request that merely takes ten seconds is a mutation, not a workflow.\n//~ entity: quote\n//~ lang: tsx\n\n//~ steps:\n//~ `useStartWorkflow` and `useWorkflowStatus` are generated only when the project has at\n//~ least one workflow, and they are typed from the workflow map rather than the RPC map.\n//~ The run id is the whole handle: hold it, and the browser can close, the tab can reload,\n//~ the server can redeploy, and the gate is still there when someone comes back for it.\n//~ Which is why it belongs in a URL or a row, not only in React state.\n// ===== FILE: src/components/quote-gate.tsx =====\nimport { useState } from 'react'\nimport { useStartWorkflow, useWorkflowStatus } from '../pikku/api.gen'\n\nexport const QuoteGate = ({ quoteId }: { quoteId: string }) => {\n const [runId, setRunId] = useState<string | undefined>()\n\n const start = useStartWorkflow('quoteApprovalWorkflow', {\n //~ The start call returns as soon as the run is durable, so what comes back\n //~ is a receipt, not an outcome. Everything after this point is the status\n //~ query's job.\n onSuccess: ({ runId }) => setRunId(runId),\n })\n\n //~ Disabled until there is a run id — the generated hook already guards on it,\n //~ so there is nothing to branch on here. Polling every few seconds is the\n //~ honest default for a gate a human answers.\n const status = useWorkflowStatus('quoteApprovalWorkflow', runId, {\n refetchInterval: 5_000,\n })\n\n if (!runId) {\n return (\n <button\n onClick={() => start.mutate({ quoteId })}\n disabled={start.isPending}\n >\n {start.isPending ? 'Sending…' : 'Send for approval'}\n </button>\n )\n }\n\n return (\n <section>\n <p>Approval run {runId}</p>\n {/*~ `suspended` is the state that makes this a workflow: the run is alive,\n waiting on a person, and costing nothing while it waits. */}\n {status.data?.status === 'suspended' ? (\n <p>Waiting on a dispatcher…</p>\n ) : null}\n {status.data?.status === 'completed' ? <p>Decided.</p> : null}\n {status.data?.status === 'failed' ? (\n <p role=\"alert\">\n {status.data.error?.message ?? 'The approval failed'}\n </p>\n ) : null}\n </section>\n )\n}\n" }, { "name": "update-mutation", "title": "Mutation RPCs (write + RETURNING) — for a SIGNED-IN user", "when": "A SIGNED-IN user's form or button WRITES to the DB (the client calls usePikkuMutation). If the write must work WITHOUT signing in (a public/anonymous form or webhook) use the public-facing-rpc scaffold instead.", "lang": "ts", "entity": "todo", "deferUntil": "", "requires": [], "steps": "═══ CLIENT SIDE — every mutation MUST invalidate the queries it affects ═══\nA write that doesn't invalidate leaves the UI showing STALE data (the #1 \"my app\nlooks broken\" bug). In the component calling this RPC, grab the query client and\ninvalidate every list/detail query the write changed — pass ONLY the rpc name as\nthe queryKey. Use mutation.isPending / mutation.error for button + error state,\nNEVER hand-managed useState:\n\n const queryClient = useQueryClient()\n const mutation = usePikkuMutation('updateTodo', {\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: ['listTodos'] })\n // invalidate EVERY query whose data this write changed — add each:\n // queryClient.invalidateQueries({ queryKey: ['getTodo'] })\n },\n })\n // <Button loading={mutation.isPending} onClick={() => mutation.mutate({ id, title })}>\n // {mutation.error && <Text c=\"red\">{asI18n(mutation.error.message)}</Text>}\nNo write ships without its invalidation(s).", "source": "packages/cli/examples/data/update-mutation.ts", "content": "//~ name: update-mutation\n//~ title: Mutation RPCs (write + RETURNING) — for a SIGNED-IN user\n//~ when: A SIGNED-IN user's form or button WRITES to the DB (the client calls usePikkuMutation). If the write must work WITHOUT signing in (a public/anonymous form or webhook) use the public-facing-rpc scaffold instead.\n//~ entity: todo\n//~ Two files, two DIFFERENT authorization shapes — the second is not decoration. A row\n//~ the caller does not own directly (a comment belongs to a todo, and the TODO has the\n//~ owner) cannot be scoped with `.where('userId', …)`, and without a real check any\n//~ signed-in user can write onto another user's row. That is a cross-tenant write /\n//~ IDOR, and it ships silently. Writing both files means the gated shape is present in\n//~ the app as code rather than as a warning the agent may or may not have acted on.\n\n//~ steps:\n//~ ═══ CLIENT SIDE — every mutation MUST invalidate the queries it affects ═══\n//~ A write that doesn't invalidate leaves the UI showing STALE data (the #1 \"my app\n//~ looks broken\" bug). In the component calling this RPC, grab the query client and\n//~ invalidate every list/detail query the write changed — pass ONLY the rpc name as\n//~ the queryKey. Use mutation.isPending / mutation.error for button + error state,\n//~ NEVER hand-managed useState:\n//~\n//~ const queryClient = useQueryClient()\n//~ const mutation = usePikkuMutation('updateTodo', {\n//~ onSuccess: () => {\n//~ queryClient.invalidateQueries({ queryKey: ['listTodos'] })\n//~ // invalidate EVERY query whose data this write changed — add each:\n//~ // queryClient.invalidateQueries({ queryKey: ['getTodo'] })\n//~ },\n//~ })\n//~ // <Button loading={mutation.isPending} onClick={() => mutation.mutate({ id, title })}>\n//~ // {mutation.error && <Text c=\"red\">{asI18n(mutation.error.message)}</Text>}\n//~ No write ships without its invalidation(s).\n// ===== FILE: packages/functions/src/functions/update-todo.function.ts =====\nimport { z } from 'zod'\nimport { pikkuFunc } from '#pikku/function'\n//~ CRITICAL: DB columns come from the GENERATED DB zod (`#pikku/db/zod.gen.js`) —\n//~ `<Table>InsertZ` for creates, `<Table>PatchZ` (or `<Table>Z.partial()`) for\n//~ edits, `<Table>Z.pick()` for what you return. Re-listing an existing column by\n//~ hand is the bug (it drifts). Computed/non-column fields you DO declare yourself\n//~ via `.extend()`.\nimport { TodoZ, TodoPatchZ } from '#pikku/db/zod.gen.js'\n\n//~ PUBLIC write? STOP — use the `public-facing-rpc` scaffold instead. Every pattern here\n//~ is pikkuFunc + auth: true, which 403s anonymous callers, so a public/unauthenticated\n//~ form wired to one of these silently never submits.\n\n//~ AUTHORIZATION lives in the `permissions` field — NEVER as an `if (!allowed) throw`\n//~ inside `func`. Two different things people confuse:\n//~ • SCOPING a query to the signed-in user is NOT authorization. Filtering by\n//~ `.where('userId', '=', session.userId)` in the query IS the correct way to\n//~ read/write the user's OWN rows — keep it in `func` (this file).\n//~ • An OWNERSHIP / ROLE gate IS authorization → put it in `permissions` (next file).\n//~ - session-only answer (is-signed-in, a role)? → pikkuAuth. This is the default.\n//~ - needs a DB lookup or the input (resource ownership)? → pikkuPermission.\n//~ `permissions` VALUES ARE pikkuAuth/pikkuPermission OBJECTS, NEVER STRINGS. Writing\n//~ `permissions: ['authenticated']` gives `Type 'string' is not assignable to type\n//~ 'PikkuPermission'` [TS2769] on the pikkuFunc(...) line. For a plain \"must be signed\n//~ in\", DON'T use permissions at all — set `auth: true`.\n\n//~ THE ROW THE CALLER OWNS → scope in the query, no permission entry needed.\n//~ The `id` is a REAL column, so PICK it off the row zod and `.merge()` it with the\n//~ editable patch fields — NEVER `.extend({ id: z.string() })`. Hand-writing\n//~ `z.string()` for an existing column is the drift the header warns about (and it is\n//~ wrong if id is a branded uuid). merge = id (required) + fields (optional):\nexport const UpdateTodoInput = TodoZ.pick({ id: true }).merge(\n TodoPatchZ.pick({ title: true }).extend({ title: z.string().min(1) }),\n)\n\n//~ Output = the columns you return, picked off the generated row zod.\nexport const UpdateTodoOutput = TodoZ.pick({ id: true, title: true })\n\nexport const updateTodo = pikkuFunc({\n expose: true,\n auth: true, //~ no `readonly` here — this mutates\n description: \"Update one of the signed-in user's todos.\",\n input: UpdateTodoInput,\n output: UpdateTodoOutput,\n func: async ({ kysely }, input, { session }) => {\n //~ Scope writes to session.userId — the `.where` pair is the row AND the guard.\n //~ .returning(...) gets the row back in one round-trip (SQLite/libSQL and Postgres).\n //~ Dates/booleans: the generated db schema type is the source of truth. Default:\n //~ a SQLite date column types as string — write `new Date().toISOString()`. Want\n //~ real Date/boolean types? Declare the kind in db/annotations.ts\n //~ (`todos: { created_at: { kind: 'date' }, done: { kind: 'bool' } }`) before\n //~ pikku-db — the wired coercion plugin then converts storage both ways. Never\n //~ write `new Date()` against a string-typed column or cast around it.\n const row = await kysely\n .updateTable('todo')\n .set({ title: input.title })\n .where('id', '=', input.id)\n .where('userId', '=', session!.userId)\n .returning(['id', 'title'])\n .executeTakeFirstOrThrow()\n return { id: row.id, title: row.title }\n },\n})\n\n// ===== FILE: packages/functions/src/functions/add-todo-comment.function.ts =====\nimport { z } from 'zod'\nimport { pikkuAuth, pikkuPermission } from '#pikku/auth'\nimport { pikkuFunc } from '#pikku/function'\nimport { TodoCommentZ, TodoCommentInsertZ } from '#pikku/db/zod.gen.js'\n\n//~ ⚠️ THIS FILE NEEDS A SECOND TABLE. The file above touches ONE table; this one touches\n//~ a CHILD of it, so `<Child>Z`/`<Child>InsertZ` only exist once that child table has a\n//~ migration AND `pikku db migrate` has regenerated the zod. Write the file before that and\n//~ TypeScript resolves both schemas to `any` and codegen dies with PKU489 naming this\n//~ function — the single most common way a build stalls (10 of 20 measured runs, every\n//~ one of them an `add<Entity>Comment`). So, in this order: write the child table's\n//~ migration in `db/<engine>/*.sql` → `pikku db migrate` → then this file.\n//~\n//~ AND PICK A CHILD YOU ACTUALLY NEED. The lesson here is INDIRECT OWNERSHIP, not\n//~ comments — if this app already has a child row hanging off its main entity (a job's\n//~ updates, an order's line items, a booking's notes), point this pattern at THAT and\n//~ write no new table at all. A comment feature nobody asked for is a table, a\n//~ migration and a screen the brief never wanted.\n\n//~ A comment has no `userId` of its own — it belongs to a todo, and the TODO has the\n//~ owner. So it cannot be scoped with `.where('userId', …)`. Without a check, ANY\n//~ signed-in user could comment on ANOTHER user's todo. The ownership check needs a DB\n//~ lookup, so it is a `pikkuPermission` (not `pikkuAuth`), and it goes in `permissions`.\n\n//~ Session-only checks are pikkuAuth — reach for this FIRST when the answer is in the\n//~ session alone. (Here to show the shape; the gate below is on ownership.)\nexport const isAuthenticated = pikkuAuth(async (_services, session) => !!session)\n\n//~ Data-aware check: does the target todo belong to the caller? `input` is the\n//~ function's input; `session` is the 3rd arg.\nexport const ownsParentTodo = pikkuPermission(\n async ({ kysely }, input: { todoId: string }, { session }) => {\n const todo = await kysely\n .selectFrom('todo')\n .select('userId')\n .where('id', '=', input.todoId)\n .executeTakeFirst()\n return todo?.userId === session?.userId\n },\n)\n\nexport const AddTodoCommentInput = TodoCommentInsertZ.pick({ todoId: true, body: true }).extend({\n body: z.string().min(1),\n})\n\nexport const AddTodoCommentOutput = TodoCommentZ.pick({ id: true, todoId: true, body: true })\n\nexport const addTodoComment = pikkuFunc({\n expose: true,\n auth: true,\n description: 'Add a comment to a todo the caller owns.',\n input: AddTodoCommentInput,\n output: AddTodoCommentOutput,\n //~ Authorization HERE, before func runs. Groups are OR'd; an array within a group\n //~ is AND'd — e.g. `owner: [isAuthenticated, ownsParentTodo]` requires both.\n permissions: {\n owner: ownsParentTodo,\n },\n //~ By the time func runs, ownership is already proven — no `if (!ok) throw` here.\n func: async ({ kysely }, input) => {\n const row = await kysely\n .insertInto('todoComment')\n .values({ todoId: input.todoId, body: input.body })\n .returning(['id', 'todoId', 'body'])\n .executeTakeFirstOrThrow()\n return { id: row.id, todoId: row.todoId, body: row.body }\n },\n})\n" }, { "name": "wire-config", "title": "Typed config — defineVariable (non-secret) + defineSecret (sensitive)", "when": "The app needs a piece of real, app-specific config the brief calls for — a third-party API key, a configurable external URL/id. Do NOT invent config (there is NO ambient \"app URL\"); only wire what the brief actually needs.", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/wires/wire-config.ts", "content": "//~ name: wire-config\n//~ title: Typed config — defineVariable (non-secret) + defineSecret (sensitive)\n//~ when: The app needs a piece of real, app-specific config the brief calls for — a third-party API key, a configurable external URL/id. Do NOT invent config (there is NO ambient \"app URL\"); only wire what the brief actually needs.\nimport { z } from 'zod'\n//~ defineVariable/defineSecret come from the generated '#pikku/variables' and\n//~ '#pikku/secrets' leaves, like every other door an app reaches for. Declaring a\n//~ wire makes its `name` a typed key on the injected variables/secrets service.\nimport { defineVariable } from '#pikku/variables'\nimport { defineSecret } from '#pikku/secrets'\n\n//~ Each wire lives in its OWN *.config.ts file (co-locating wirings makes pikku\n//~ SKIP them — \"metadata not found\"). `name` is the KEY you read by; `variableId`\n//~ / `secretId` is the underlying store id (env var name / vault key). `schema`\n//~ validates the value at load. Run pikku-verify after adding one so the typed\n//~ variables/secrets map regenerates.\n\n//~ NON-SECRET config (URLs, ids, feature flags) → defineVariable:\ndefineVariable({\n name: 'PARTNER_API_URL',\n displayName: 'Partner API URL',\n description: 'Base URL of the external partner API.',\n variableId: 'PARTNER_API_URL',\n schema: z.string().url(),\n})\n\n//~ SENSITIVE config (keys, tokens, passwords) → defineSecret. rotationPeriod is\n//~ optional metadata ('30day', '1w') so consumers can tell when it's due.\ndefineSecret({\n name: 'PARTNER_API_KEY',\n displayName: 'Partner API key',\n description: 'Bearer key for the partner API.',\n secretId: 'PARTNER_API_KEY',\n schema: z.string().min(1),\n rotationPeriod: '90day',\n})\n\n//~ READ them in any function via the injected typed services — SINGULAR methods,\n//~ which resolve the value type from the generated map. NEVER the plural\n//~ getVariables/getSecrets with an inline type param (those return unknown):\n//~ func: async ({ variables, secrets }, input) => {\n//~ const url = await variables.get('PARTNER_API_URL') // string\n//~ // getSecret returns a SecretValue wrapper; .reveal() is the one way out\n//~ const key = (await secrets.getSecret('PARTNER_API_KEY')).reveal() // string\n//~ // ...call the partner API with url + key\n//~ }\n" }, { "name": "wire-scope", "title": "Scopes — coarse capability gates (defineScope tree + grant in mapSession + gate a function)", "when": "The app has CAPABILITIES that only some users may exercise — admin-only actions, \"can void an invoice\", \"can manage members\", a billing-manager tier. Scopes are the RIGHT tool for a role/capability AND-gate; `permissions` (pikku-permissions) is for per-request OWNERSHIP/data predicates (is this MY row, my org). Use BOTH: a scope says \"may this KIND of user do this at all\", a permission says \"may they touch THIS record\". Lay this down when roles first appear; pull `auth-session` too (scopes are granted there) and `feature-flags` (the client surface — without it the UI looks identical for every role).", "lang": "ts", "entity": "", "deferUntil": "", "requires": [], "steps": "SCOPES vs PERMISSIONS — pick the right one:\n • scopes: AND gate, checked BEFORE permissions, depend ONLY on the session,\n can only NARROW access. \"Is this user allowed this capability at all?\"\n Declared here, granted in mapSession, required on a function as\n `scopes: ['admin:invoices:void']`.\n • permissions: OR groups evaluated against the request DATA (ownership, org match).\n \"Does this specific record belong to them?\" See pikku-permissions.\n\n⚠️ FAIL-CLOSED — THE ONE RULE YOU MUST NOT BREAK. `verifyScopes` runs the moment a\nfunction declares `scopes`, whether or not anyone was granted them. A session whose\n`scopes` does NOT include the required id (or a parent / `*`) is DENIED with\nMissingScopeError. So: NEVER put `scopes: [...]` on a function unless mapSession\n(STEP 2) actually grants that scope to the users who should pass — otherwise the\nfunction 403s EVERYONE, including the admin. Grant first, gate second.\n\nSTEP 1 — DECLARE the vocabulary. `defineScope` is a no-op the CLI AST-reads to\ngenerate a typed `ScopeId` union, so a function requiring an undeclared scope is a\nCOMPILE error. Scopes nest by segment and join with ':' — the tree below yields\n`admin`, `admin:invoices`, `admin:invoices:void`, `admin:members`, `billing`.\nHolding a PARENT grants everything beneath it (`admin` covers `admin:invoices:void`);\n`*` grants all. Keep it small and semantic — one node per real capability, not per route.\n\nSTEP 2 — GRANT scopes to a session in mapSession (see the `auth-session` scaffold).\nmapSession sets `session.scopes` and it is AUTHORITATIVE — no ScopeService needed for\na role-driven app. Derive scopes from the user's role; admins hold everything via `*`.\nThe first commented block below is the shape: it goes in your session middleware (the\nfile the `auth-session` scaffold has you create,\npackages/functions/src/middleware/session.middleware.ts), inside\nbetterAuthStatelessSession. A DB-backed grant model — per-user grants, a console grant\nUI — is a ScopeService; that is beyond an app scaffold. For generated apps, resolve\nscopes from role there.\n\nSTEP 3 — REQUIRE the scope on the capability. Add `scopes: [...]` to the pikkuFunc\nthat performs the privileged action (NOT in the func body — same discipline as\npermissions). Combine with a `permissions` ownership check where the action also\ntouches a specific record — the second commented block below shows both gates on one\nfunction. A read/list function that everyone may see needs NO scope — leave it\nungated. Only gate the genuinely privileged writes/actions; over-gating is how a fresh\napp locks its own admin out. Test the DENY path with the `scenario-permissions` scaffold.\n\nSTEP 4 — GIVE THE FRONTEND SOMETHING TO READ: `pikku examples add --name feature-flags`.\nSteps 1–3 secure the SERVER and change nothing on screen — scopes never cross the\nwire, so without this step every role sees an identical UI and the roles you just\nbuilt are invisible. That is the #1 way a role-based app ships looking like it has\nno roles at all. The `feature-flags` recipe adds one `getFeatures` RPC that maps\nscopes to UI-facing feature names the client can hide/show on.\n⚠️ It HIDES, it does not protect: keep the `scopes: [...]` from STEP 3 on every\nfunction whose control you hide. Hide AND gate — a hidden button with an ungated\nRPC is an app that only LOOKS secure.", "source": "packages/cli/examples/wires/wire-scope.ts", "content": "//~ name: wire-scope\n//~ title: Scopes — coarse capability gates (defineScope tree + grant in mapSession + gate a function)\n//~ when: The app has CAPABILITIES that only some users may exercise — admin-only actions, \"can void an invoice\", \"can manage members\", a billing-manager tier. Scopes are the RIGHT tool for a role/capability AND-gate; `permissions` (pikku-permissions) is for per-request OWNERSHIP/data predicates (is this MY row, my org). Use BOTH: a scope says \"may this KIND of user do this at all\", a permission says \"may they touch THIS record\". Lay this down when roles first appear; pull `auth-session` too (scopes are granted there) and `feature-flags` (the client surface — without it the UI looks identical for every role).\n//~ lang: ts\n//~ steps:\n//~ SCOPES vs PERMISSIONS — pick the right one:\n//~ • scopes: AND gate, checked BEFORE permissions, depend ONLY on the session,\n//~ can only NARROW access. \"Is this user allowed this capability at all?\"\n//~ Declared here, granted in mapSession, required on a function as\n//~ `scopes: ['admin:invoices:void']`.\n//~ • permissions: OR groups evaluated against the request DATA (ownership, org match).\n//~ \"Does this specific record belong to them?\" See pikku-permissions.\n//~\n//~ ⚠️ FAIL-CLOSED — THE ONE RULE YOU MUST NOT BREAK. `verifyScopes` runs the moment a\n//~ function declares `scopes`, whether or not anyone was granted them. A session whose\n//~ `scopes` does NOT include the required id (or a parent / `*`) is DENIED with\n//~ MissingScopeError. So: NEVER put `scopes: [...]` on a function unless mapSession\n//~ (STEP 2) actually grants that scope to the users who should pass — otherwise the\n//~ function 403s EVERYONE, including the admin. Grant first, gate second.\n//~\n//~ STEP 1 — DECLARE the vocabulary. `defineScope` is a no-op the CLI AST-reads to\n//~ generate a typed `ScopeId` union, so a function requiring an undeclared scope is a\n//~ COMPILE error. Scopes nest by segment and join with ':' — the tree below yields\n//~ `admin`, `admin:invoices`, `admin:invoices:void`, `admin:members`, `billing`.\n//~ Holding a PARENT grants everything beneath it (`admin` covers `admin:invoices:void`);\n//~ `*` grants all. Keep it small and semantic — one node per real capability, not per route.\n//~\n//~ STEP 2 — GRANT scopes to a session in mapSession (see the `auth-session` scaffold).\n//~ mapSession sets `session.scopes` and it is AUTHORITATIVE — no ScopeService needed for\n//~ a role-driven app. Derive scopes from the user's role; admins hold everything via `*`.\n//~ The first commented block below is the shape: it goes in your session middleware (the\n//~ file the `auth-session` scaffold has you create,\n//~ packages/functions/src/middleware/session.middleware.ts), inside\n//~ betterAuthStatelessSession. A DB-backed grant model — per-user grants, a console grant\n//~ UI — is a ScopeService; that is beyond an app scaffold. For generated apps, resolve\n//~ scopes from role there.\n//~\n//~ STEP 3 — REQUIRE the scope on the capability. Add `scopes: [...]` to the pikkuFunc\n//~ that performs the privileged action (NOT in the func body — same discipline as\n//~ permissions). Combine with a `permissions` ownership check where the action also\n//~ touches a specific record — the second commented block below shows both gates on one\n//~ function. A read/list function that everyone may see needs NO scope — leave it\n//~ ungated. Only gate the genuinely privileged writes/actions; over-gating is how a fresh\n//~ app locks its own admin out. Test the DENY path with the `scenario-permissions` scaffold.\n//~\n//~ STEP 4 — GIVE THE FRONTEND SOMETHING TO READ: `pikku examples add --name feature-flags`.\n//~ Steps 1–3 secure the SERVER and change nothing on screen — scopes never cross the\n//~ wire, so without this step every role sees an identical UI and the roles you just\n//~ built are invisible. That is the #1 way a role-based app ships looking like it has\n//~ no roles at all. The `feature-flags` recipe adds one `getFeatures` RPC that maps\n//~ scopes to UI-facing feature names the client can hide/show on.\n//~ ⚠️ It HIDES, it does not protect: keep the `scopes: [...]` from STEP 3 on every\n//~ function whose control you hide. Hide AND gate — a hidden button with an ungated\n//~ RPC is an app that only LOOKS secure.\nimport { defineScope } from '#pikku/scopes'\n\ndefineScope({\n admin: {\n displayName: 'Administration',\n description: 'Administrative capabilities',\n scopes: {\n invoices: {\n description: 'Manage invoices',\n scopes: {\n void: { description: 'Void an invoice' },\n },\n },\n members: { description: 'Manage members' },\n },\n },\n billing: { displayName: 'Billing', description: 'View and manage billing' },\n})\n\n//\n// // in your session middleware (the file the `auth-session` scaffold has you create,\n// // packages/functions/src/middleware/session.middleware.ts), inside betterAuthStatelessSession:\n// mapSession: (result) => {\n// const role = (result.user as { role?: string }).role\n// return {\n// userId: result.user.id,\n// role,\n// // '*' grants every scope; a finance user gets only what they need.\n// scopes:\n// role === 'admin' ? ['*']\n// : role === 'finance' ? ['admin:invoices', 'billing']\n// : [],\n// }\n// }\n//\n\n//\n// export const voidInvoice = pikkuFunc({\n// // coarse gate: only an invoice-voider may call this at all …\n// scopes: ['admin:invoices:void'],\n// // … fine gate: and only for an invoice in their own org.\n// permissions: { orgMatch: canAccessOrgByInvoiceId },\n// input: z.object({ invoiceId: z.string() }),\n// output: z.object({ voided: z.boolean() }),\n// func: async ({ kysely }, { invoiceId }) => {\n// await kysely.updateTable('invoice').set({ status: 'void' }).where('invoiceId', '=', invoiceId).execute()\n// return { voided: true }\n// },\n// })\n//\n" }, { "name": "workflow", "title": "Durable multi-step workflow (pikkuWorkflowFunc DSL + parallel fan-out + cron start)", "when": "The app needs a background multi-step process — daily digest, onboarding sequence, status rollup. This IS the canonical shape. The DSL supports if/else, for..of, and Promise.all(array.map()) — fan out per item with workflow.do named steps (parallel + durable). NEVER pikkuWorkflowComplexFunc / pikkuWorkflowGraph (approval-gated escape hatches). PKU641 = a const/let DECLARED inside a block — hoist the declaration to the top of the body and assign in the block.", "lang": "ts", "entity": "digest", "deferUntil": "", "requires": [], "steps": "", "source": "packages/cli/examples/workflows/workflow.ts", "content": "//~ name: workflow\n//~ title: Durable multi-step workflow (pikkuWorkflowFunc DSL + parallel fan-out + cron start)\n//~ when: The app needs a background multi-step process — daily digest, onboarding sequence, status rollup. This IS the canonical shape. The DSL supports if/else, for..of, and Promise.all(array.map()) — fan out per item with workflow.do named steps (parallel + durable). NEVER pikkuWorkflowComplexFunc / pikkuWorkflowGraph (approval-gated escape hatches). PKU641 = a const/let DECLARED inside a block — hoist the declaration to the top of the body and assign in the block.\n//~ entity: digest\n//~ Four files, and the SPLIT is the teaching. A wire* sharing a file with the workflow\n//~ func makes pikku SKIP the task at runtime (\"Skipping scheduled task — metadata not\n//~ found\") and the cron silently never fires; a *.workflow.ts holding several\n//~ pikkuSessionlessFunc exports bloats codegen until it times out (\"codegen exited\n//~ null\"). Both used to be warnings the agent had to honour by hand. Now the layout\n//~ arrives correct and the warnings are gone.\n\n// ===== FILE: packages/functions/src/functions/list-digest-recipients.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ STEP FUNCS are normal pikku funcs the workflow calls BY NAME via workflow.do.\n//~ input/output are ALWAYS named consts (even an empty {}) — never inline at the\n//~ input:/output: site; pikku rejects inline expressions with PKU489.\n//~ A step that needs NO auth is a pikkuSessionlessFunc; if it gates, permissions values\n//~ are pikkuAuth/pikkuPermission OBJECTS, never strings (`permissions: ['authenticated']`\n//~ → `Type 'string' not assignable to PikkuPermission`). Plain \"signed in\" = `auth: true`.\nexport const ListDigestRecipientsInput = z.object({})\nexport const ListDigestRecipientsOutput = z.object({\n recipients: z.array(z.object({ id: z.string(), email: z.string(), name: z.string().nullable() })),\n})\n\nexport const listDigestRecipients = pikkuSessionlessFunc({\n expose: true, //~ steps must be registered RPCs so workflow.do can dispatch them\n readonly: true,\n description: 'All users who should receive the daily digest.',\n input: ListDigestRecipientsInput,\n output: ListDigestRecipientsOutput,\n func: async ({ kysely }) => {\n const rows = await kysely.selectFrom('user').select(['id', 'email', 'name']).execute()\n return { recipients: rows.map((r) => ({ id: r.id, email: r.email, name: r.name ?? null })) }\n },\n})\n\n// ===== FILE: packages/functions/src/functions/send-digest-email.function.ts =====\nimport { z } from 'zod'\nimport { pikkuSessionlessFunc } from '#pikku/function'\n\n//~ ONE recipient per step — the workflow fans these out in PARALLEL, so each send is\n//~ its own durable, retryable step in the graph view.\nexport const SendDigestEmailInput = z.object({\n email: z.string(),\n name: z.string().nullable(),\n})\nexport const SendDigestEmailOutput = z.object({ ok: z.boolean() })\n\nexport const sendDigestEmail = pikkuSessionlessFunc({\n expose: true,\n description: 'Send the digest email to one recipient.',\n input: SendDigestEmailInput,\n output: SendDigestEmailOutput,\n func: async ({ emailService }, input) => {\n //~ 'dailyDigest' must exist as emails/templates/daily-digest.html + locale keys\n //~ (see {name: transactional-email} and the pikku-emails skill).\n await emailService.send({\n to: input.email,\n template: { name: 'dailyDigest', data: { firstName: input.name ?? 'there' } },\n })\n return { ok: true }\n },\n})\n\n// ===== FILE: packages/functions/src/workflows/daily-digest.workflow.ts =====\nimport { z } from 'zod'\nimport { pikkuWorkflowFunc } from '#pikku/workflow'\n\n//~ Plain pikkuWorkflowFunc DSL. Control flow allowed in the body: if/else, for..of, and\n//~ Promise.all(array.map()). Declare every const/let at the TOP level of the body\n//~ (PKU641 fires on declarations inside blocks).\nexport const DailyDigestWorkflowInput = z.object({})\nexport const DailyDigestWorkflowOutput = z.object({ sent: z.number() })\n\nexport const dailyDigestWorkflow = pikkuWorkflowFunc({\n description: 'Collect recipients and send the daily digest to each in parallel.',\n input: DailyDigestWorkflowInput,\n output: DailyDigestWorkflowOutput,\n func: async (_services, _data, { workflow }) => {\n const audience = await workflow.do('Collect recipients', 'listDigestRecipients', {})\n\n //~ PARALLEL FAN-OUT — one named durable step per recipient, all at once.\n await Promise.all(\n audience.recipients.map(\n async (r) =>\n await workflow.do(`Send digest to ${r.email}`, 'sendDigestEmail', {\n email: r.email,\n name: r.name,\n }),\n ),\n )\n\n return { sent: audience.recipients.length }\n },\n})\n\n// ===== FILE: packages/functions/src/wires/cron/daily-digest.scheduler.ts =====\nimport { pikkuVoidFunc } from '#pikku/function'\nimport { wireScheduler } from '#pikku/scheduler'\n\n//~ CRON START — a tiny scheduled func starts the workflow; wireScheduler wires it.\n//~ Scheduled funcs MUST use pikkuVoidFunc (void→void, no input/output schemas) —\n//~ pikkuSessionlessFunc does NOT typecheck against wireScheduler. Schedule is\n//~ 5-field cron (07:00 UTC daily).\nexport const startDailyDigest = pikkuVoidFunc({\n title: 'Start daily digest',\n func: async (_services, _input, { rpc }) => {\n await rpc.startWorkflow('dailyDigestWorkflow', {})\n },\n})\n\nwireScheduler({\n name: 'startDailyDigest',\n schedule: '0 7 * * *',\n func: startDailyDigest,\n})\n" }];