okengine 0.22.0 → 0.23.1

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 (312) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +14 -10
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/channel/index.mdx +4 -4
  13. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  14. package/site/content/docs/elements/channel/sms.mdx +2 -1
  15. package/site/content/docs/elements/flow/index.mdx +22 -18
  16. package/site/content/docs/elements/gate/auth.mdx +4 -1
  17. package/site/content/docs/elements/signal/index.mdx +15 -14
  18. package/site/content/docs/elements/store/index.mdx +1 -1
  19. package/site/content/docs/elements/store/kv.mdx +5 -5
  20. package/site/content/docs/plugins/otp.mdx +3 -3
  21. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  22. package/site/content/docs/recipes/index.mdx +6 -6
  23. package/site/content/docs/recipes/postgres.mdx +12 -8
  24. package/site/content/docs/recipes/redis.mdx +4 -4
  25. package/site/content/docs/reference/cli.mdx +2 -1
  26. package/site/content/docs/reference/configuration.mdx +9 -7
  27. package/site/content/docs/reference/fx.mdx +19 -10
  28. package/src/auth/api-key-sql.test.ts +44 -0
  29. package/src/auth/api-key-sql.ts +1 -0
  30. package/src/auth/api-keys.ts +30 -12
  31. package/src/auth/config.ts +6 -2
  32. package/src/auth/gate-auth.test.ts +20 -0
  33. package/src/auth/tenants.ts +17 -0
  34. package/src/cli/agents-md.test.ts +25 -0
  35. package/src/cli/ask-vault-gaps.test.ts +38 -1
  36. package/src/cli/ask-vault-gaps.ts +35 -2
  37. package/src/cli/decide.ts +174 -0
  38. package/src/cli/decision-lock-watch.test.ts +45 -0
  39. package/src/cli/decision-lock-watch.ts +51 -0
  40. package/src/cli/dev-app-runner.ts +34 -8
  41. package/src/cli/dev-auth-secret.test.ts +49 -0
  42. package/src/cli/dev-auth-secret.ts +49 -0
  43. package/src/cli/dev-hot.test.ts +41 -0
  44. package/src/cli/dev-hot.ts +48 -0
  45. package/src/cli/dev.test.ts +1 -1
  46. package/src/cli/dev.ts +24 -6
  47. package/src/cli/doctor-diff.test.ts +29 -1
  48. package/src/cli/doctor-diff.ts +35 -0
  49. package/src/cli/eval.ts +142 -1
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/load-config.images.test.ts +2 -2
  52. package/src/cli/load-config.ts +1 -1
  53. package/src/cli/manifest-pr-diff.ts +16 -0
  54. package/src/cli/mcp-from-console.ts +48 -0
  55. package/src/cli/registry.ts +35 -1
  56. package/src/client/agent.ts +307 -0
  57. package/src/client/transport.test.ts +20 -0
  58. package/src/client/transport.ts +2 -1
  59. package/src/client/types.ts +1 -1
  60. package/src/client-react/index.ts +7 -1
  61. package/src/client-react/use-agent-run.test.ts +271 -0
  62. package/src/client-react/use-agent-run.ts +193 -0
  63. package/src/compiler/ai-approval.test.ts +36 -0
  64. package/src/compiler/ai-repair.test.ts +22 -0
  65. package/src/compiler/decisions.extract.test.ts +164 -0
  66. package/src/compiler/effects-fetch.test.ts +77 -0
  67. package/src/compiler/effects-infer.ts +114 -17
  68. package/src/compiler/effects-join.test.ts +48 -0
  69. package/src/compiler/extract.test.ts +28 -0
  70. package/src/compiler/extract.ts +279 -13
  71. package/src/compiler/fx-index.ts +58 -4
  72. package/src/compiler/response.ts +35 -2
  73. package/src/config/index.ts +21 -3
  74. package/src/console/index.ts +1 -1
  75. package/src/console/server/ai-runs-flows.ts +269 -0
  76. package/src/console/server/ai-runs.test.ts +323 -0
  77. package/src/console/server/ai-runs.ts +562 -0
  78. package/src/console/server/ai.ts +12 -1
  79. package/src/console/server/app.ts +10 -1
  80. package/src/console/server/decisions-flows.ts +157 -0
  81. package/src/console/server/decisions.test.ts +66 -0
  82. package/src/console/server/decisions.ts +229 -0
  83. package/src/console/server/flows.ts +50 -2
  84. package/src/console/server/invoke-user-flow.ts +9 -0
  85. package/src/console/server/runs-ingest.ts +16 -0
  86. package/src/console/server/serve.ts +5 -0
  87. package/src/console/server/state.ts +25 -1
  88. package/src/console/server/store-stats.test.ts +47 -0
  89. package/src/console/server/store-stats.ts +92 -18
  90. package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
  91. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
  95. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  96. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  97. package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
  98. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  99. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  100. package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
  101. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  102. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  103. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  104. package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
  105. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
  107. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  108. package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
  109. package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
  110. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
  111. package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
  112. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  113. package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
  114. package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
  115. package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
  116. package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
  117. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  118. package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
  119. package/src/console/ui-next/dist/index.html +4 -3
  120. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  121. package/src/console/ui-next/src/client.ts +224 -1
  122. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  123. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  124. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  125. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  126. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  127. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  128. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  129. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  130. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  131. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  132. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  133. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  134. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  135. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  136. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  137. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  138. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  139. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  140. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  141. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  142. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  143. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  144. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  145. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  146. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  147. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  148. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  149. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  150. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  151. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  152. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  153. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  154. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  155. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  156. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  157. package/src/console/ui-next/src/router.tsx +17 -0
  158. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  159. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  160. package/src/console/ui-next/vite.config.ts +9 -0
  161. package/src/docker/recipes/dragonfly.ts +3 -2
  162. package/src/docker/recipes/redis.ts +1 -1
  163. package/src/drivers/ai-anthropic.ts +212 -21
  164. package/src/drivers/ai-mock.ts +34 -1
  165. package/src/drivers/ai-openai-compatible.ts +67 -26
  166. package/src/drivers/ai-preconnect.ts +18 -0
  167. package/src/drivers/ai-stream.test.ts +62 -0
  168. package/src/drivers/ai-types.ts +20 -0
  169. package/src/drivers/journal-postgres.test.ts +22 -1
  170. package/src/drivers/journal-postgres.ts +511 -13
  171. package/src/drivers/memory-ddl.test.ts +18 -0
  172. package/src/drivers/memory.ts +21 -1
  173. package/src/drivers/pg-rls.test.ts +31 -0
  174. package/src/drivers/pg-rls.ts +15 -0
  175. package/src/drivers/postgres.test.ts +9 -0
  176. package/src/drivers/postgres.ts +49 -11
  177. package/src/drivers/signal-compete.test.ts +234 -0
  178. package/src/drivers/signal-postgres.ts +39 -19
  179. package/src/drivers/signal-redis.ts +167 -11
  180. package/src/drivers/signal-types.ts +12 -0
  181. package/src/elements/ai/agent-event-slot.ts +27 -0
  182. package/src/elements/ai/approval-http.ts +207 -0
  183. package/src/elements/ai/approval.test.ts +741 -0
  184. package/src/elements/ai/approval.ts +228 -0
  185. package/src/elements/ai/decisions/bind.ts +163 -0
  186. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  187. package/src/elements/ai/decisions/certificate.ts +377 -0
  188. package/src/elements/ai/decisions/certify.ts +314 -0
  189. package/src/elements/ai/decisions/decide.test.ts +953 -0
  190. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  191. package/src/elements/ai/decisions/export.test.ts +120 -0
  192. package/src/elements/ai/decisions/export.ts +146 -0
  193. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  194. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  195. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  196. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  197. package/src/elements/ai/decisions/http.test.ts +154 -0
  198. package/src/elements/ai/decisions/http.ts +240 -0
  199. package/src/elements/ai/decisions/labels.test.ts +282 -0
  200. package/src/elements/ai/decisions/labels.ts +257 -0
  201. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  202. package/src/elements/ai/decisions/openrouter.ts +36 -0
  203. package/src/elements/ai/decisions/provider.ts +102 -0
  204. package/src/elements/ai/decisions/typesafe.ts +36 -0
  205. package/src/elements/ai/declare.ts +252 -1
  206. package/src/elements/ai/events.test.ts +379 -0
  207. package/src/elements/ai/events.ts +138 -0
  208. package/src/elements/ai/mcp-protocol.ts +5 -6
  209. package/src/elements/ai/run-events.test.ts +461 -0
  210. package/src/elements/ai/run-events.ts +522 -0
  211. package/src/elements/ai/runtime.ts +1187 -105
  212. package/src/elements/ai/stream-turn.ts +214 -0
  213. package/src/elements/ai/stream.live.test.ts +76 -0
  214. package/src/elements/ai/subagent.test.ts +136 -0
  215. package/src/elements/ai.test.ts +497 -3
  216. package/src/elements/ai.ts +16 -1
  217. package/src/elements/channel/runtime.ts +11 -0
  218. package/src/elements/channel/sql-ledger.ts +251 -0
  219. package/src/elements/clock/durable.ts +27 -1
  220. package/src/elements/signal/runtime.ts +9 -0
  221. package/src/elements/signal.test.ts +1 -0
  222. package/src/elements/store/cache.test.ts +120 -15
  223. package/src/elements/store/cache.ts +200 -39
  224. package/src/elements/store/prepare-row.test.ts +6 -2
  225. package/src/elements/store/sql-session.test.ts +53 -0
  226. package/src/elements/store/sql-session.ts +206 -10
  227. package/src/elements/store.ts +1 -0
  228. package/src/i18n/catalogs/ar.ts +5 -0
  229. package/src/i18n/catalogs/en.ts +5 -0
  230. package/src/index.ts +2 -0
  231. package/src/kernel/agent-event-store.ts +448 -0
  232. package/src/kernel/app.ts +135 -17
  233. package/src/kernel/auto-cache.test.ts +228 -0
  234. package/src/kernel/boot-bind/ai.ts +3 -0
  235. package/src/kernel/boot-bind/channel.ts +63 -2
  236. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  237. package/src/kernel/boot-bind/signal.ts +61 -25
  238. package/src/kernel/boot-bind/store.test.ts +26 -0
  239. package/src/kernel/boot-bind/store.ts +82 -6
  240. package/src/kernel/boot.ts +31 -6
  241. package/src/kernel/builtin-errors.ts +2 -0
  242. package/src/kernel/capability.ts +3 -0
  243. package/src/kernel/decision-label-store.ts +441 -0
  244. package/src/kernel/effects.test.ts +2 -1
  245. package/src/kernel/effects.ts +13 -1
  246. package/src/kernel/element-registries.ts +3 -0
  247. package/src/kernel/errors-compiler.ts +11 -0
  248. package/src/kernel/errors-text.ts +20 -0
  249. package/src/kernel/errors.ts +10 -0
  250. package/src/kernel/flow.test.ts +2 -3
  251. package/src/kernel/flow.ts +33 -3
  252. package/src/kernel/fx-call-types.test.ts +32 -0
  253. package/src/kernel/fx-decide.ts +765 -0
  254. package/src/kernel/fx-sql-handle.ts +46 -7
  255. package/src/kernel/fx.test.ts +13 -8
  256. package/src/kernel/fx.ts +339 -45
  257. package/src/kernel/http-frame.test.ts +94 -0
  258. package/src/kernel/http-frame.ts +203 -0
  259. package/src/kernel/idempotency.test.ts +2 -1
  260. package/src/kernel/journal.test.ts +114 -0
  261. package/src/kernel/journal.ts +471 -51
  262. package/src/kernel/json-result.ts +10 -0
  263. package/src/kernel/pipeline-tenant.ts +5 -1
  264. package/src/kernel/pipeline.test.ts +45 -0
  265. package/src/kernel/pipeline.ts +5 -0
  266. package/src/kernel/signal-tx.ts +28 -0
  267. package/src/kernel/sse-id.ts +32 -0
  268. package/src/kernel/store-transaction.test.ts +95 -0
  269. package/src/manifest/diff.ts +1 -0
  270. package/src/manifest/types.ts +29 -2
  271. package/src/mcp/ai-tools.test.ts +202 -0
  272. package/src/mcp/authorization.ts +66 -0
  273. package/src/mcp/docs-server.ts +19 -2
  274. package/src/mcp/mcp.test.ts +83 -0
  275. package/src/mcp/protocol.ts +8 -2
  276. package/src/mcp/server.ts +23 -2
  277. package/src/mcp/session.ts +3 -0
  278. package/src/mcp/tools.ts +168 -0
  279. package/src/mcp/versions.ts +60 -0
  280. package/src/plugins/index.ts +1 -0
  281. package/src/plugins/mena.ts +56 -0
  282. package/src/release/http-graph.test.ts +2 -0
  283. package/src/release/http-graph.ts +5 -0
  284. package/src/release/measure.ts +39 -14
  285. package/src/runs/collect.ts +4 -1
  286. package/src/runs/parquet.test.ts +27 -1
  287. package/src/runs/parquet.ts +19 -0
  288. package/src/runs/types.ts +31 -0
  289. package/src/runtime/json-code-block.test.ts +106 -47
  290. package/src/runtime/json-code-block.ts +1209 -46
  291. package/src/term.test.ts +13 -0
  292. package/src/term.ts +81 -49
  293. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  294. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  295. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  296. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  297. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  298. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  299. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  300. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  301. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  302. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  303. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  304. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  305. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  306. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  307. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  308. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  309. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  310. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  311. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  312. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -0,0 +1,373 @@
1
+ ---
2
+ title: "Events"
3
+ description: "Stream an agent run to a chat UI as AG-UI events over server-sent events."
4
+ icon: "Radio"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ An agent stream is the same tool loop as `fx.run`, delivered while the model is still working. A support chat shows tokens, tool calls, and a finish reason as server-sent events.
9
+
10
+ Return `fx.json.stream` from the HTTP Flow. Parse the frames with `okengine/client/agent`.
11
+
12
+ <Callout title="The one rule">
13
+ `return fx.json.stream(fx.run(agent, input, { stream: true }))`. `stream` is the third argument.
14
+ `fx.stream` yields plain text from a model, not these events.
15
+ </Callout>
16
+
17
+ ## Quick start
18
+
19
+ <Steps>
20
+
21
+ <Step>
22
+ ### Declare the agent
23
+
24
+ ```typescript title="src/core/ai.ts"
25
+ import { ai } from "okengine";
26
+
27
+ export const smart = ai.model("smart", {
28
+ provider: "openrouter",
29
+ model: "openrouter/free",
30
+ });
31
+
32
+ export const support = ai.agent("support", {
33
+ model: smart,
34
+ tools: ["orders.get"],
35
+ maxSteps: 4,
36
+ });
37
+ ```
38
+
39
+ </Step>
40
+
41
+ <Step>
42
+ ### Stream it from HTTP
43
+
44
+ ```typescript title="src/flows/support/assist.ts"
45
+ import { on, flow, http } from "okengine";
46
+ import { z } from "zod";
47
+ import { support } from "@/core/ai";
48
+
49
+ export const assist = on(
50
+ http.post({ in: z.object({ message: z.string().min(1) }) }).public(),
51
+ flow({
52
+ do: ({ message }, fx) => fx.json.stream(fx.run(support, { message }, { stream: true })),
53
+ }),
54
+ );
55
+ ```
56
+
57
+ The response is `text/event-stream`. Pass your own `threadId` when the chat already has one. The default is a unique id. It is not a per-process counter.
58
+
59
+ </Step>
60
+
61
+ <Step>
62
+ ### Read the frames
63
+
64
+ ```bash
65
+ curl -N -X POST http://localhost:6530/support/assist \
66
+ -H "content-type: application/json" \
67
+ -d '{"message":"Where is order 14?"}'
68
+ ```
69
+
70
+ Each `data:` line is one JSON event. A text reply with no tool call looks like this (ids vary):
71
+
72
+ ```text
73
+ data: {"type":"RUN_STARTED","threadId":"agent-run-1","runId":"agent-run-1"}
74
+
75
+ data: {"type":"STEP_STARTED","stepName":"step-1"}
76
+
77
+ data: {"type":"TEXT_MESSAGE_START","messageId":"m-1","role":"assistant"}
78
+
79
+ data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m-1","delta":"Order 14 is open."}
80
+
81
+ data: {"type":"TEXT_MESSAGE_END","messageId":"m-1"}
82
+
83
+ data: {"type":"STEP_FINISHED","stepName":"step-1"}
84
+
85
+ data: {"type":"RUN_FINISHED","threadId":"agent-run-1","runId":"agent-run-1","result":{"cost":0.01,"stopReason":"completed","output":"Order 14 is open."},"usage":[{"inputTokens":3,"outputTokens":2}]}
86
+
87
+ data: [DONE]
88
+ ```
89
+
90
+ `usage` is present only when the model reported token counts. `cost` and `stopReason` are on `result`, not on `usage`.
91
+
92
+ </Step>
93
+
94
+ <Step>
95
+ ### Parse with the client
96
+
97
+ ```typescript
98
+ import { readAgentEvents } from "okengine/client/agent";
99
+
100
+ const response = await fetch("http://localhost:6530/support/assist", {
101
+ method: "POST",
102
+ headers: { "content-type": "application/json" },
103
+ body: JSON.stringify({ message: "Where is order 14?" }),
104
+ });
105
+
106
+ for await (const event of readAgentEvents(response)) {
107
+ if (event.type === "TEXT_MESSAGE_CONTENT") process.stdout.write(event.delta);
108
+ }
109
+ ```
110
+
111
+ Import `okengine/client/agent`. That module stays off `okengine/client`.
112
+
113
+ </Step>
114
+
115
+ </Steps>
116
+
117
+ ## Event reference
118
+
119
+ Core names match AG-UI. Subagent notices are `CUSTOM`, not extra types.
120
+
121
+ | Type | Fields | When it fires |
122
+ | ---------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `RUN_STARTED` | `threadId`, `runId` | The run begins. `threadId` is the option you passed, or the run id. |
124
+ | `STEP_STARTED` | `stepName` | A step starts. `maxSteps` counts tool calls, not model calls. Names are `step-1`, `step-2`, … |
125
+ | `TEXT_MESSAGE_START` | `messageId`, `role` | Assistant text is about to arrive. `role` is `assistant`. Empty text is skipped. |
126
+ | `TEXT_MESSAGE_CONTENT` | `messageId`, `delta` | One piece of assistant text. A streaming driver emits one frame per delta. A driver without `stream` emits the whole turn in one frame. |
127
+ | `TEXT_MESSAGE_END` | `messageId` | That text message is complete. |
128
+ | `TOOL_CALL_START` | `toolCallId`, `toolCallName`, `parentMessageId?` | The model named a tool. `parentMessageId` is set when that step emitted assistant text. |
129
+ | `TOOL_CALL_ARGS` | `toolCallId`, `delta` | One piece of the tool arguments. Streaming drivers emit each args delta. A driver without `stream` emits one JSON string. |
130
+ | `TOOL_CALL_END` | `toolCallId` | Arguments are complete. The tool has not run yet. |
131
+ | `TOOL_CALL_RESULT` | `messageId`, `toolCallId`, `content`, `role` | The tool returned. `role` is `tool`. `content` is a string. |
132
+ | `STEP_FINISHED` | `stepName` | That model step is done, including its tool calls. |
133
+ | `RUN_FINISHED` | `threadId`, `runId`, `result?`, `usage?`, `outcome?` | The loop stopped, or a tool is waiting for approval. |
134
+ | `RUN_ERROR` | `message`, `code?` | The run failed and did not finish. `code` is the error name when it is not `Error`. |
135
+ | `CUSTOM` | `name`, `value` | A nested agent started, finished, or failed. |
136
+
137
+ `RUN_FINISHED.result` is `{ cost, stopReason, output }`. `cost` is the sum of model-reported spend for this run. `output` is the last tool result. A text reply with no tool call may be the raw provider payload, not the assistant string.
138
+
139
+ `RUN_FINISHED.usage` is a one-element array, `{ inputTokens?, outputTokens? }`, and only when those numbers came from the model. Counts are never invented.
140
+
141
+ `messageId` values are `m-1`, `m-2`, … inside the run. A missing provider tool-call id becomes `agent:step:index`.
142
+
143
+ ## Tool approval
144
+
145
+ A tool with `approval` and `gate` parks a durable Flow. The stream emits `RUN_FINISHED` with `outcome.type` `"interrupt"`, then `data: [DONE]`, then the response ends. The browser does not stay open. The approval id is base64url of `runId.toolCallId`, so it carries the durable Flow run id.
146
+
147
+ ```json
148
+ {
149
+ "type": "RUN_FINISHED",
150
+ "threadId": "agent-run-1",
151
+ "runId": "agent-run-1",
152
+ "outcome": {
153
+ "type": "interrupt",
154
+ "interrupts": [
155
+ {
156
+ "id": "VjFTdEdYUjhfWjVqZEhpNkItbXlULmNhbGxfMQ",
157
+ "reason": "approval",
158
+ "payload": { "tool": "refund", "args": { "amount": 10 } }
159
+ }
160
+ ]
161
+ }
162
+ }
163
+ ```
164
+
165
+ `id` is base64url of `<durable Flow run id>.<tool call id>`. The example decodes to `V1StGXR8_Z5jdHi6B-myT.call_1`. `V1StGXR8_Z5jdHi6B-myT` is the journal run id, not `agent-run-1`. `runId` on the frame is the agent stream id.
166
+
167
+ Show `payload.tool` and `payload.args`. Keep `id`. That id is the only handle for the decision.
168
+
169
+ Resolve it from a Flow, or from the built-in routes. The routes are public so the request can arrive. The tool's gate is checked on the call. A denying gate is 403. A missing id, or a tenant that does not match the park, is 404.
170
+
171
+ ```typescript
172
+ await fx.agent.approve(id, { args: { amount: 4 } });
173
+ await fx.agent.deny(id, { reason: "over the limit" });
174
+ ```
175
+
176
+ ```bash
177
+ curl -X POST http://localhost:6530/agent/approvals/approve \
178
+ -H "content-type: application/json" \
179
+ -d '{"id":"VjFTdEdYUjhfWjVqZEhpNkItbXlULmNhbGxfMQ","args":{"amount":4}}'
180
+ ```
181
+
182
+ ```bash
183
+ curl -X POST http://localhost:6530/agent/approvals/deny \
184
+ -H "content-type: application/json" \
185
+ -d '{"id":"VjFTdEdYUjhfWjVqZEhpNkItbXlULmNhbGxfMQ","reason":"over the limit"}'
186
+ ```
187
+
188
+ `args` replaces the tool input. `reason` is what the model sees after a deny. The first decision wins. The default wait is `24h`, then the tool is denied with reason `timeout`.
189
+
190
+ | Outcome | HTTP | What you do |
191
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
192
+ | `{ ok: true }` | 200 `{ "data": { "ok": true }, "error": null }` | The journal sleep wakes now. |
193
+ | Already resolved | 409 `Conflict`. No `Retry-After`. Message: `That value is already in use.` | Stop. Do not retry. |
194
+ | Lease held | 409 `JournalLeaseBusy` with `Retry-After`. Message: `This run is locked by another worker. Retry after the given delay.` | Retry after the delay. |
195
+
196
+ Approving or denying does not continue this SSE response. Follow the run to see the rest.
197
+
198
+ ## Follow a run
199
+
200
+ `GET /agent/runs/:runId/events` is `text/event-stream`. Each frame has an `id`. Send that id as `Last-Event-ID` to resume without gaps or duplicates. The route checks the same gate and tenant as the Flow that started the run. Another tenant, or a gate that denies the caller, is rejected.
201
+
202
+ An approval interrupt does not end this follow. The stream stays open through the interrupt and continues to the terminal `RUN_FINISHED`. The first run stream still closes after the interrupt. Disconnect, then resume with the last id you received.
203
+
204
+ The run stream yields every token. The follow log coalesces text and argument deltas (about every 100 ms or 1 KB, including a timer) and stores structural events one by one. The instance that holds the journal lease is the only writer. It reads the highest seq before it appends, and a repeated seq is an error. Followers read `seq` greater than the last id, not the whole run.
205
+
206
+ The log keeps 5,000 rows per run. Past that, deltas stop and one `CUSTOM` event named `oke.events.truncated` is stored. Structural events, approval interrupts, `RUN_FINISHED`, and `RUN_ERROR` are still stored. Earlier rows stay, so a follower can resume and seq numbers do not skip. A finished run's events are deleted after 24 hours. An unfinished run older than 7 days is closed with `RUN_ERROR` and deleted. The scheduler reads the store, not the process that opened the run. One instance claims the journal lease before it closes that run. The log is stored on the journal driver and keyed by the agent run id. `flow.retry` keeps that id. A follower must be the starting principal or an operator, and must pass every gate on the Flow.
207
+
208
+ The file journal is single-process. It is not for large logs. Use the Postgres journal driver in production.
209
+
210
+ A stored row on the live run stream carries `id:` set to that row's seq. Resume with `Last-Event-ID` so text you already showed is not sent again.
211
+
212
+ ```typescript
213
+ import { approve, deny, readAgentEvents } from "okengine/client/agent";
214
+
215
+ for await (const event of readAgentEvents(`http://localhost:6530/agent/runs/${runId}/events`)) {
216
+ if (event.type === "RUN_FINISHED" && event.outcome?.type === "interrupt") {
217
+ await approve("http://localhost:6530/agent/approvals/approve", event.outcome.interrupts[0]!.id);
218
+ }
219
+ }
220
+ ```
221
+
222
+ `approve` and `deny` retry `JournalLeaseBusy` and throw `Conflict` when the decision is already stored. `useAgentRun` from `okengine/client-react` sends the message, keeps the text so far, and follows once after an interrupt. Approve and deny do not open a second follow.
223
+
224
+ ## Patterns
225
+
226
+ <Tabs items={["One message", "History", "Thread id", "Subagents"]}>
227
+
228
+ <Tab value="One message">
229
+
230
+ A string and `{ message }` are the same turn. Passing both throws `fx.run: pass message or messages, not both`.
231
+
232
+ ```typescript
233
+ return fx.json.stream(fx.run(support, "Where is order 14?", { stream: true }));
234
+ ```
235
+
236
+ </Tab>
237
+
238
+ <Tab value="History">
239
+
240
+ `messages` is the prior thread. Roles are `system`, `user`, `assistant`, and `tool`.
241
+
242
+ ```typescript
243
+ return fx.json.stream(
244
+ fx.run(
245
+ support,
246
+ {
247
+ messages: [
248
+ { role: "user", content: "Where is order 14?" },
249
+ { role: "assistant", content: "Looking it up." },
250
+ { role: "user", content: "Refund it." },
251
+ ],
252
+ },
253
+ { stream: true },
254
+ ),
255
+ );
256
+ ```
257
+
258
+ </Tab>
259
+
260
+ <Tab value="Thread id">
261
+
262
+ Pass `threadId` when the chat already has one. The default is a unique id.
263
+
264
+ ```typescript
265
+ return fx.json.stream(fx.run(support, { message }, { stream: true, threadId: chatId }));
266
+ ```
267
+
268
+ `RUN_STARTED` and `RUN_FINISHED` both carry that `threadId`.
269
+
270
+ `mock`, `anthropic`, and `openai-compatible` stream tokens and tool-call arguments. `bedrock` and `vertex` are reserved driver ids: boot throws before a call. `fx.stream` uses the same `stream` method, so it works on Anthropic too.
271
+
272
+ </Tab>
273
+
274
+ <Tab value="Subagents">
275
+
276
+ A tool that names another agent emits `CUSTOM` on the parent stream. `okengine/client/agent` also returns typed names.
277
+
278
+ | `CUSTOM.name` | Parsed `type` | `value` |
279
+ | ----------------------- | ------------------- | ---------------------------- |
280
+ | `oke.subagent.started` | `subagent.started` | `runId`, `parentToolCallId?` |
281
+ | `oke.subagent.finished` | `subagent.finished` | `runId`, `parentToolCallId?` |
282
+ | `oke.subagent.error` | `subagent.error` | `runId`, `parentToolCallId?` |
283
+
284
+ Any other `CUSTOM` name stays `CUSTOM`. Nesting stops at `maxDepth` (default 3).
285
+
286
+ </Tab>
287
+
288
+ </Tabs>
289
+
290
+ ## Text stream or event stream
291
+
292
+ | Call | What each SSE `data:` frame is |
293
+ | -------------------------------------------------------- | ---------------------------------------------- |
294
+ | `fx.json.stream(fx.stream(model, { prompt }))` | A JSON string of plain text (`"Hel"`, `"lo"`). |
295
+ | `fx.json.stream(fx.run(agent, input, { stream: true }))` | One AG-UI event object. |
296
+
297
+ `fx.stream` is the model text iterator. `fx.json.stream` is the SSE carrier. Agent UIs use the second row.
298
+
299
+ ## Abort and stop reasons
300
+
301
+ Disconnecting the HTTP client aborts the in-flight model call. The run records `stopReason` `aborted`. If the frame is still written, it is `RUN_ERROR` with `code` `AbortError`. `message` may be the provider's abort text, not only `aborted`.
302
+
303
+ `result.stopReason` on a normal `RUN_FINISHED`:
304
+
305
+ | `stopReason` | Meaning |
306
+ | ------------ | ------------------------------------------------------------------------------------------ |
307
+ | `completed` | The model stopped calling tools. A gate denial fed back to the model stays `completed`. |
308
+ | `max_steps` | The loop hit `maxSteps` (default 6). |
309
+ | `budget` | Spend reached `budget.maxCostPerRun`. The trail so far is the output. This does not throw. |
310
+
311
+ `denied` (unknown tool) and `aborted` arrive as `RUN_ERROR`, not as `result.stopReason`. A thrown tool ends with `RUN_FINISHED` and `result.stopReason` `"error"`, then `data: [DONE]`. The same close follows an approval interrupt: the final frame, then `[DONE]`, then the body ends.
312
+
313
+ ## Troubleshooting
314
+
315
+ <Accordions>
316
+
317
+ <Accordion title='ai: flow "support.assist" must set durable: true to run agent "support"'>
318
+ The tool has `approval`, and the Flow is not durable. The message names that Flow: `ai: flow
319
+ "support.assist" must set durable: true to run agent "support"`. Set `durable: true`. A false
320
+ `approval` predicate does not park.
321
+ </Accordion>
322
+
323
+ <Accordion title='RUN_ERROR: ai: model requested unknown tool "refund"'>
324
+ The model called a tool that is not on `ai.agent({tools})`. `code` is `AgentLoopHalt`. Add the
325
+ Flow name to `tools`, or stop offering it in the prompt.
326
+ </Accordion>
327
+
328
+ <Accordion title="RUN_FINISHED stopReason is budget">
329
+ `result.stopReason` is `budget` and `result.cost` is the spend so far. Raise
330
+ `budget.maxCostPerRun`, or treat the partial `output` as the reply. A prompt's `maxCostPerCall` is
331
+ different: `fx.ask` throws `ai: prompt "…" exceeded maxCostPerCall …` (`AiBudgetExceededError`).
332
+ </Accordion>
333
+
334
+ <Accordion title="The client disconnected">
335
+ The model call aborts. You may see `RUN_ERROR` with message `aborted` and code `AbortError`, or
336
+ only a closed socket. The recorded `stopReason` is `aborted`.
337
+ </Accordion>
338
+
339
+ <Accordion title="A frame is not one of the core types">
340
+ `readAgentEvents` yields `CUSTOM` unchanged when `name` is not `oke.subagent.started`,
341
+ `oke.subagent.finished`, or `oke.subagent.error`. Those three become `subagent.started`,
342
+ `subagent.finished`, and `subagent.error`. An object with no `type` is skipped. `data: [DONE]` is
343
+ skipped. The iterator ends when the body ends, not because of that line.
344
+ </Accordion>
345
+
346
+ <Accordion title="Conflict or JournalLeaseBusy">
347
+ A second approve or deny after the decision is stored is `Conflict`. Do not retry it. A lost race
348
+ while another worker holds the run is `JournalLeaseBusy` with `Retry-After`. Retry only that code.
349
+ </Accordion>
350
+
351
+ </Accordions>
352
+
353
+ ## Learn more
354
+
355
+ - [Agents](/docs/elements/ai/agents) — `fx.run`, tools, `maxSteps`, and approval
356
+ - [Prompts](/docs/elements/ai/prompts) — tool-less `fx.ask` still throws on `maxCostPerCall`
357
+ - [MCP](/docs/elements/ai/mcp) — external tools in the same loop
358
+
359
+ ## Next
360
+
361
+ <Cards>
362
+ <Card
363
+ title="Agents"
364
+ description="Bounded tool loops with fx.run."
365
+ href="/docs/elements/ai/agents"
366
+ />
367
+ <Card
368
+ title="Decisions"
369
+ description="One calibrated choice, with a person when it cannot auto."
370
+ href="/docs/elements/ai/decide"
371
+ />
372
+ <Card title="AI" description="Models, prompts, agents, and decisions." href="/docs/elements/ai" />
373
+ </Cards>
@@ -279,6 +279,11 @@ Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes
279
279
 
280
280
  <Accordions>
281
281
 
282
+ <Accordion title="The same input called the model again">
283
+ Outside a durable Flow, `fx.ask` always reaches the model. Replay lives only on that run's
284
+ journal.
285
+ </Accordion>
286
+
282
287
  <Accordion title='ai: unknown prompt "…"'>
283
288
  `fx.ask` named a prompt that was never minted with `model.prompt`, or the declaring module was not
284
289
  imported before `oke()`.
@@ -295,8 +300,8 @@ Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes
295
300
  </Accordion>
296
301
 
297
302
  <Accordion title="AiBudgetExceededError">
298
- Cause: `ai: prompt "…" exceeded maxCostPerCall N` or `ai: agent "…" exceeded maxCostPerRun N`.
299
- Raise the budget or shrink the work.
303
+ Cause: `ai: prompt "…" exceeded maxCostPerCall N`. A tool-less `fx.ask` throws
304
+ `AiBudgetExceededError`. `fx.run` returns the partial result when the agent budget is spent.
300
305
  </Accordion>
301
306
 
302
307
  <Accordion title="build failed: … without allowPii">
@@ -316,9 +321,10 @@ Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes
316
321
  - [Models](/docs/elements/ai/models) — provider registry and `baseUrl` rules
317
322
  - [Prompts](/docs/elements/ai/prompts) — `via`, budgets, versions, evals
318
323
  - [Agents](/docs/elements/ai/agents) — `fx.run`, tools, `maxSteps`
324
+ - [Decisions](/docs/elements/ai/decide) — `fx.decide`, `how`, and the lockfile
319
325
  - [MCP](/docs/elements/ai/mcp) — inbound `mcp.tool` and outbound `ai.mcpServer`
320
326
  - [OpenRouter](/docs/recipes/openrouter) — zero-Docker cloud path
321
- - [fx](/docs/reference/fx) — `fx.ask` / `fx.run` / `fx.embed`
327
+ - [fx](/docs/reference/fx) — `fx.ask` / `fx.run` / `fx.decide` / `fx.embed`
322
328
  - [Errors](/docs/reference/errors) — OKE1005 · OKE1009
323
329
 
324
330
  ## Next
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "title": "AI",
3
3
  "icon": "Sparkles",
4
- "pages": ["index", "models", "prompts", "agents", "mcp"]
4
+ "pages": ["index", "models", "prompts", "agents", "decide", "events", "mcp"]
5
5
  }
@@ -128,8 +128,9 @@ smart.prompt("ticket-triage", {
128
128
  });
129
129
  ```
130
130
 
131
- `maxCostPerRun` on a prompt budget is reserved for multi-step asks; agents use
132
- `budget.maxCostPerRun` on `ai.agent`.
131
+ A tool-less ask still throws `AiBudgetExceededError` at `maxCostPerCall`. An agent run
132
+ returns the partial result when `budget.maxCostPerRun` is spent. `repair: 1` sends one
133
+ follow-up of the original messages plus the mismatch; it counts toward the cap and is its own journal entry.
133
134
 
134
135
  </Tab>
135
136
 
@@ -161,6 +162,7 @@ Default `maxSteps` is `6`. Tool names also stamp `effects.calls`.
161
162
  | `version` | `number` | — | Pin with `fx.ask("name@N")` |
162
163
  | `evals` | `string` | — | Path for `oke eval` JSONL |
163
164
  | `budget` | `{ maxCostPerCall?, maxCostPerRun? }` | — | Cost contracts |
165
+ | `repair` | `0` \| `1` | `0` | One schema-mismatch follow-up; counts toward `maxCostPerCall` |
164
166
  | `via` | `string[]` | `[parent model]` | Recovery chain of logical names |
165
167
  | `timeout` | `"30s"` \| ms | — | Ask deadline (not a cost budget) |
166
168
  | `in` | Schema | — | Declared / Manifest / doctor — **not** runtime-validated on ask |
@@ -247,7 +247,7 @@ overrides). Manifest stays free of copy.
247
247
  | Method | Capability / `sends` | Meaning |
248
248
  | -------------------------- | -------------------- | ------------------------------------------- |
249
249
  | `fx.send(template, opts?)` | template name | Deliver through the medium’s driver chain |
250
- | `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP (Taqnyat Verify) |
250
+ | `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP. |
251
251
  | `fx.verifyOtp(opts)` | `"sms-otp"` | Check a provider OTP code |
252
252
  | `fx.deliverOtp(opts)` | `"auth-otp"` | App-owned OTP across email / SMS / WhatsApp |
253
253
 
@@ -351,9 +351,9 @@ Env knobs: [Environment variables](/docs/reference/environment-variables). Local
351
351
  </Accordion>
352
352
 
353
353
  <Accordion title="Process-local suppression / receipts warning">
354
- Boot warns: `Channel suppression/consent/receipts default to process-local memory…` Opt-out on one
355
- instance is invisible to others until you inject shared stores or run a single Channel consumer.
356
- See [Receipts](/docs/elements/channel/receipts).
354
+ Boot warns: `Channel suppression/consent/receipts default to process-local memory…` That is
355
+ `test`, or a boot with no `DATABASE_URL`. Otherwise consent, suppression, and receipts are the
356
+ Postgres ledger. See [Receipts](/docs/elements/channel/receipts).
357
357
  </Accordion>
358
358
 
359
359
  </Accordions>
@@ -1,21 +1,23 @@
1
1
  ---
2
2
  title: "Receipts"
3
- description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression — process-local by default."
3
+ description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression."
4
4
  icon: "ReceiptText"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
8
  Every Channel send records a **receipt** — success (`sent` / `fallback`), suppression, or a
9
9
  classified failure. Provider bounces and complaints update that ledger through normalized
10
- outcomes. There is no `oke_receipts` SQL table and no `fx.channel.getReceipt` helper.
10
+ outcomes. There is no `fx.channel.getReceipt` helper. Outside `test`, boot stores consent,
11
+ suppression, and receipts in Postgres (`oke_channel_consent`, `oke_channel_bounce`,
12
+ `oke_channel_receipt`) when `DATABASE_URL` is set. `test` stays in memory.
11
13
 
12
14
  For operators watching deliverability — Console projects the ledger; Flows only see send
13
15
  results via `fx.send`’s `{ ok: true }` gate.
14
16
 
15
17
  <Callout title="The one rule">
16
- Consent and prior hard bounces suppress **before** any driver runs. Default suppression / consent
17
- / receipts stores are **process-local memory** — inject shared stores for multi-instance, or run a
18
- single Channel consumer, until a durable driver ships.
18
+ Consent and prior hard bounces suppress **before** any driver runs. With `DATABASE_URL` outside
19
+ `test`, those rows are shared across instances. `test`, and any boot without a SQL URL, keep
20
+ process-local memory and still warn.
19
21
  </Callout>
20
22
 
21
23
  ## Smallest Example
@@ -7,7 +7,8 @@ source: "docs/spec/unified-theory.md"
7
7
 
8
8
  SMS (`channel.sms`) delivers short text and provider-managed one-time codes. Pin an SMS driver
9
9
  (no default in any env), declare templates for app-owned messages, or call `fx.sendOtp` when the
10
- vendor owns the code.
10
+ vendor owns the code. `fx.sendOtp` and `fx.verifyOtp` stay on `fx`. The `mena` plugin opens the
11
+ same providers when you want that packaging.
11
12
 
12
13
  For developers verifying phones — choose raw Channel OTP vs the [`otp`](/docs/plugins/otp) plugin.
13
14
 
@@ -220,17 +220,17 @@ Second argument to `flow(name, options)` — or the only argument to a nameless
220
220
  Invoke contracts (`in` / `out` / `errors` / `breaking`) belong on the exposure — see
221
221
  [Contracts](#contracts) below.
222
222
 
223
- | Option | Type | Default | Meaning |
224
- | -------------- | -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- |
225
- | `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
226
- | `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
227
- | `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
228
- | `cache` | `boolean \| string` | omitted (auto) | Read-only Flows cache automatically; `false` opts out; `"30s"` adds TTL |
229
- | `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
230
- | `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
231
- | `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
232
- | `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
233
- | `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
223
+ | Option | Type | Default | Meaning |
224
+ | -------------- | -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
225
+ | `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
226
+ | `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
227
+ | `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
228
+ | `cache` | `boolean \| string` | omitted (on when pure) | Pure store reads cache; `false` disables; `"30s"` sets a TTL; app `cache: { auto: false }` turns the default off |
229
+ | `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
230
+ | `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
231
+ | `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
232
+ | `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
233
+ | `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
234
234
 
235
235
  **Consequence:** `durable: true` disables automatic read-cache for that Flow.
236
236
 
@@ -438,12 +438,12 @@ durable Flow, or split with `fx.emit`. See [Workflows](/docs/elements/flow/workf
438
438
 
439
439
  <Tab value="Cache">
440
440
 
441
- Read-only Flows (Store `reads`, no `writes`, no `asks`, not durable) cache automatically.
442
- No `cache:` option required. Mutations and `durable: true` stay uncached:
441
+ A pure store-read Flow caches on its own. A send, emit, fetch, vault read, ask,
442
+ decide, or `fx.call` is never cached. The key includes the flow, the input, the
443
+ user, the tenant, the locale, and the caller's scopes and membership roles.
443
444
 
444
445
  ```typescript
445
446
  flow("catalog.get", {
446
- cache: "30s",
447
447
  do: async ({ id }, fx) => {
448
448
  const [row] = await fx.store(db).select().from(products).where(eq(products.id, id));
449
449
  return row;
@@ -451,7 +451,10 @@ flow("catalog.get", {
451
451
  });
452
452
  ```
453
453
 
454
- `cache: false` opts out. A duration string adds TTL on top of write invalidation.
454
+ `cache: false` always disables. A duration string adds TTL on top of write
455
+ invalidation. `oke({ cache: { auto: false } })` turns the default off; a Flow
456
+ can still set `cache: true` or `"30s"`. Cross-instance invalidation and writes
457
+ that skip `fx` are not covered.
455
458
 
456
459
  </Tab>
457
460
 
@@ -570,9 +573,10 @@ Default is `true` once `gate.auth.tenant` is on.
570
573
  </Accordion>
571
574
 
572
575
  <Accordion title="Read-only Flow never cache-hits">
573
- Auto-cache needs Store `reads`, no `writes`, no `asks`, and `durable` off. Empty effect sets stay
574
- uncached. Opt in with a duration (`cache: "30s"`) only after a real read is inferred — or pass
575
- `cache: false` to disable.
576
+ A pure Store read caches unless the Flow sets `cache: false` or the app sets
577
+ `cache: { auto: false }`. A send, emit, fetch, secret, ask, decide, call, or write
578
+ is never cached, and `durable: true` is never cached. The key includes tenant,
579
+ locale, scopes, and roles, so a hit for one caller is a miss for another.
576
580
  </Accordion>
577
581
 
578
582
  <Accordion title='cross-plane call: user flow "…" calls operator flow "…"'>
@@ -103,8 +103,11 @@ export const app = oke({
103
103
  });
104
104
  ```
105
105
 
106
- In production, set `gate.auth.secret` (or `OKE_AUTH_SECRET`). Omitting it in `prod` throws:
106
+ In production, set `gate.auth.secret` (or `OKE_AUTH_SECRET`). Omitting both in `prod` throws:
107
107
  `gate.auth: secret is required in production (set gate.auth.secret or OKE_AUTH_SECRET)`.
108
+ When `gate.auth.secret` is omitted, the process uses `OKE_AUTH_SECRET`. `oke dev` writes one
109
+ value into `.env.local` and passes it to Console and the app, so a Bearer API key created
110
+ in Console verifies on the app.
108
111
 
109
112
  Forged, expired, or revoked access tokens map to typed `Unauthorized` — they never become a
110
113
  principal.
@@ -331,11 +331,11 @@ Omit `key` for a pure competing pool with no ordering.
331
331
  Protocol ids: `memory` · `redis` · `postgres` · `nats`. Defaults (when `oke.config.ts` omits
332
332
  `drivers.signal`):
333
333
 
334
- | Env | Default | Boot today |
335
- | ------ | -------- | ----------------------------------------------------------------------- |
336
- | `dev` | `redis` | Emit relays to Redis; consume / live / drain use a process-local outbox |
337
- | `test` | `memory` | In-process bus |
338
- | `prod` | `redis` | Same redis honesty as `dev` |
334
+ | Env | Default | Boot today |
335
+ | ------ | -------- | -------------------------------------------------------------------------------- |
336
+ | `dev` | `redis` | Emit relays to Redis. `once` consume is a consumer group (`XREADGROUP` / `XACK`) |
337
+ | `test` | `memory` | In-process bus |
338
+ | `prod` | `redis` | Same redis path as `dev` |
339
339
 
340
340
  ```typescript title="oke.config.ts"
341
341
  export default defineConfig({
@@ -345,8 +345,10 @@ export default defineConfig({
345
345
  });
346
346
  ```
347
347
 
348
- `postgres` and `nats` fail loud at boot until a native bind ships — never silently fall back to
349
- `memory`. Prefer `memory` for tests; pin `redis` when Compose provides Redis.
348
+ `postgres` binds Bun.SQL and the scheduler polls `drain` (`FOR UPDATE SKIP LOCKED`). Bun.SQL has no
349
+ `LISTEN` / `NOTIFY`. `nats` still fails loud at boot — never silently fall back to `memory`. Prefer
350
+ `memory` for tests; pin `redis` when Compose provides Redis. Live history stays on the emitting
351
+ process; `once` is the competing path.
350
352
 
351
353
  ## Troubleshooting
352
354
 
@@ -383,15 +385,14 @@ export default defineConfig({
383
385
  consumers](#competing-consumers-once-vs-broadcast).
384
386
  </Accordion>
385
387
 
386
- <Accordion title='oke boot: signal driver "postgres" / "nats"'>
387
- Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
388
- custom `elements.signal` runtime.
388
+ <Accordion title='oke boot: signal driver "postgres" needs DATABASE_URL'>
389
+ `drivers.signal` is `postgres` and neither `DATABASE_URL` nor `OKE_STORE_SQL_URL` is set. Boot
390
+ polls `drain` on that connection. There is no `LISTEN` / `NOTIFY` requirement.
389
391
  </Accordion>
390
392
 
391
- <Accordion title="redis Signal — process-local consume">
392
- Boot warns that redis emit relays to Redis while consume / live / drain stay process-local.
393
- Multi-instance competing consumers need a shared durable outbox path (or a single consumer
394
- instance) until Redis Streams consume ships.
393
+ <Accordion title='oke boot: signal driver "nats"'>
394
+ `nats` has no production client yet. Use `"memory"`, `"redis"`, or `"postgres"`, or inject a
395
+ custom `elements.signal` runtime.
395
396
  </Accordion>
396
397
 
397
398
  </Accordions>