okengine 0.5.1 → 0.6.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 (105) hide show
  1. package/README.md +148 -13
  2. package/package.json +4 -3
  3. package/site/content/docs/elements/ai.mdx +82 -1
  4. package/site/content/docs/elements/channel.mdx +77 -8
  5. package/site/content/docs/elements/flow.mdx +20 -17
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/magic-link.mdx +27 -21
  8. package/site/content/docs/reference/configuration.mdx +12 -4
  9. package/site/content/docs/reference/environment-variables.mdx +42 -13
  10. package/site/content/docs/reference/errors.mdx +14 -0
  11. package/site/content/docs/reference/fx.mdx +68 -16
  12. package/site/content/docs/reference/i18n.mdx +313 -0
  13. package/site/content/docs/reference/index.mdx +6 -1
  14. package/site/content/docs/reference/meta.json +1 -0
  15. package/site/content/docs/reference/plugins.mdx +1 -0
  16. package/src/auth/auth.test.ts +3 -0
  17. package/src/auth/bindings.ts +1 -1
  18. package/src/auth/method-context.ts +12 -2
  19. package/src/cli/openbao-restart.integration.test.ts +106 -97
  20. package/src/compiler/aot.test.ts +16 -13
  21. package/src/compiler/effects-infer.ts +46 -0
  22. package/src/console/server/ai.test.ts +34 -5
  23. package/src/docker/compose.ts +9 -0
  24. package/src/docker/docker.test.ts +39 -0
  25. package/src/docker/dockerfile.integration.test.ts +126 -119
  26. package/src/docker/index.ts +11 -1
  27. package/src/docker/recipes/index.ts +3 -1
  28. package/src/docker/recipes/ollama.ts +43 -0
  29. package/src/docker/stack-id.ts +2 -0
  30. package/src/docker/stack.integration.test.ts +118 -102
  31. package/src/drivers/ai-mock.ts +60 -0
  32. package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
  33. package/src/drivers/ai-ollama.integration.test.ts +181 -0
  34. package/src/drivers/ai-ollama.ts +327 -0
  35. package/src/drivers/ai-openai-compatible.ts +211 -21
  36. package/src/drivers/ai-providers.test.ts +179 -2
  37. package/src/drivers/ai-stream.test.ts +195 -0
  38. package/src/drivers/ai-types.ts +42 -1
  39. package/src/drivers/channel-fcm.ts +49 -53
  40. package/src/drivers/channel-msegat.ts +61 -0
  41. package/src/drivers/channel-sently-map.ts +57 -0
  42. package/src/drivers/channel-sently.test.ts +99 -0
  43. package/src/drivers/channel-smtp.ts +8 -2
  44. package/src/drivers/channel-sndr.ts +28 -0
  45. package/src/drivers/channel-taqnyat.ts +57 -0
  46. package/src/drivers/channel-types.ts +79 -2
  47. package/src/drivers/channel-unifonic.ts +26 -43
  48. package/src/drivers/channel-wa-cloud.ts +33 -47
  49. package/src/drivers/channel-webpush.ts +39 -239
  50. package/src/drivers/index.ts +25 -1
  51. package/src/drivers/ollama.ts +14 -0
  52. package/src/elements/ai/rate.test.ts +53 -0
  53. package/src/elements/ai/rate.ts +66 -0
  54. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  55. package/src/elements/ai/runtime.ts +330 -100
  56. package/src/elements/ai/tools.test.ts +99 -0
  57. package/src/elements/ai.test.ts +26 -2
  58. package/src/elements/ai.ts +10 -1
  59. package/src/elements/channel/costs.test.ts +2 -2
  60. package/src/elements/channel/costs.ts +14 -2
  61. package/src/elements/channel/mime.ts +11 -0
  62. package/src/elements/channel/runtime.ts +94 -0
  63. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  64. package/src/elements/channel.ts +10 -1
  65. package/src/elements/index.ts +9 -0
  66. package/src/i18n/catalogs/ar.ts +67 -0
  67. package/src/i18n/catalogs/en.ts +68 -0
  68. package/src/i18n/failure-message.test.ts +56 -0
  69. package/src/i18n/failure-message.ts +93 -0
  70. package/src/i18n/format.ts +67 -0
  71. package/src/i18n/index.ts +57 -0
  72. package/src/i18n/locale-context.ts +48 -0
  73. package/src/i18n/messages.test.ts +173 -0
  74. package/src/i18n/messages.ts +169 -0
  75. package/src/i18n/types.ts +90 -0
  76. package/src/index.ts +26 -0
  77. package/src/kernel/app.ts +92 -2
  78. package/src/kernel/boot-bind/ai.test.ts +60 -0
  79. package/src/kernel/boot-bind/ai.ts +125 -2
  80. package/src/kernel/boot-bind/channel.test.ts +68 -3
  81. package/src/kernel/boot-bind/channel.ts +93 -2
  82. package/src/kernel/boot.test.ts +4 -3
  83. package/src/kernel/boot.ts +1 -1
  84. package/src/kernel/errors.ts +56 -5
  85. package/src/kernel/fx.test.ts +27 -0
  86. package/src/kernel/fx.ts +74 -18
  87. package/src/kernel/pipeline.test.ts +4 -0
  88. package/src/kernel/pipeline.ts +1 -1
  89. package/src/kernel/plugin.ts +16 -0
  90. package/src/kernel/registry.ts +15 -0
  91. package/src/plugins/auth/shared.ts +5 -1
  92. package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
  93. package/src/plugins/auth-methods.security.test.ts +12 -10
  94. package/src/plugins/email-otp.ts +54 -1
  95. package/src/plugins/index.ts +16 -2
  96. package/src/plugins/magic-link.ts +63 -3
  97. package/src/plugins/username-policy.test.ts +302 -0
  98. package/src/plugins/username.ts +290 -9
  99. package/src/release/exports.test.ts +26 -0
  100. package/src/release/exports.ts +64 -5
  101. package/src/release/index.ts +5 -0
  102. package/src/release/measure.exports.test.ts +13 -1
  103. package/src/release/measure.ts +84 -14
  104. package/src/release/official-plugins.ts +46 -0
  105. package/src/release/readme.test.ts +30 -2
package/README.md CHANGED
@@ -1,21 +1,90 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="site/public/logo/OKE-W.svg" />
4
- <img alt="OKE" src="site/public/logo/OKE-B.svg" width="220" />
3
+ <source
4
+ media="(prefers-color-scheme: dark)"
5
+ srcset="https://shieldcn.dev/header/grid.svg?title=okengine&subtitle=One+law.+Eight+elements.+Ten+exports.&logo=https://raw.githubusercontent.com/omqkhafi/okengine/main/site/public/logo/OKE-W.svg&theme=zinc&size=banner&mode=dark&font=geist&align=left"
6
+ />
7
+ <img
8
+ alt="okengine — One law. Eight elements. Ten exports."
9
+ src="https://shieldcn.dev/header/grid.svg?title=okengine&subtitle=One+law.+Eight+elements.+Ten+exports.&logo=https://raw.githubusercontent.com/omqkhafi/okengine/main/site/public/logo/OKE-B.svg&theme=zinc&size=banner&mode=light&font=geist&align=left"
10
+ width="750"
11
+ />
5
12
  </picture>
6
13
  </p>
7
14
 
8
- # okengine
9
-
10
- _"Stop gluing APIs, jobs, and queues into one backend. One law collapses them — client, Console, and infra come free. Yours to host."_
15
+ <p align="center">
16
+ <a href="https://www.npmjs.com/package/okengine">
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/npm/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
19
+ <img alt="npm" src="https://shieldcn.dev/npm/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
20
+ </picture>
21
+ </a>
22
+ <a href="https://jsr.io/@omqkhafi/okengine">
23
+ <picture>
24
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/jsr/@omqkhafi/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
25
+ <img alt="JSR" src="https://shieldcn.dev/jsr/@omqkhafi/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
26
+ </picture>
27
+ </a>
28
+ <a href="https://www.npmjs.com/package/create-oke">
29
+ <picture>
30
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/npm/create-oke.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
31
+ <img alt="create-oke" src="https://shieldcn.dev/npm/create-oke.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
32
+ </picture>
33
+ </a>
34
+ <a href="https://www.npmjs.com/package/okengine">
35
+ <picture>
36
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/npm/types/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
37
+ <img alt="types" src="https://shieldcn.dev/npm/types/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
38
+ </picture>
39
+ </a>
40
+ <a href="https://opensource.org/licenses/MIT">
41
+ <picture>
42
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/license/omqkhafi/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
43
+ <img alt="MIT" src="https://shieldcn.dev/github/license/omqkhafi/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
44
+ </picture>
45
+ </a>
46
+ <a href="https://bun.sh">
47
+ <picture>
48
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/Bun-%3E%3D1.3.svg?logo=bun&variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
49
+ <img alt="Bun >=1.3" src="https://shieldcn.dev/badge/Bun-%3E%3D1.3.svg?logo=bun&variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
50
+ </picture>
51
+ </a>
52
+ <a href="https://github.com/omqkhafi/okengine/releases">
53
+ <picture>
54
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/release/omqkhafi/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
55
+ <img alt="release" src="https://shieldcn.dev/github/release/omqkhafi/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
56
+ </picture>
57
+ </a>
58
+ <a href="https://github.com/omqkhafi/okengine/stargazers">
59
+ <picture>
60
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/stars/omqkhafi/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
61
+ <img alt="stars" src="https://shieldcn.dev/github/stars/omqkhafi/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
62
+ </picture>
63
+ </a>
64
+ <a href="https://github.com/omqkhafi/okengine/actions">
65
+ <picture>
66
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/ci/omqkhafi/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
67
+ <img alt="CI" src="https://shieldcn.dev/github/ci/omqkhafi/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
68
+ </picture>
69
+ </a>
70
+ <a href="https://github.com/omqkhafi/okengine/commits">
71
+ <picture>
72
+ <source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/last-commit/omqkhafi/okengine.svg?variant=outline&mode=dark&size=sm&theme=zinc&font=geist" />
73
+ <img alt="last commit" src="https://shieldcn.dev/github/last-commit/omqkhafi/okengine.svg?variant=outline&mode=light&size=sm&theme=zinc&font=geist" />
74
+ </picture>
75
+ </a>
76
+ </p>
11
77
 
12
- [![npm](https://img.shields.io/npm/v/okengine.svg)](https://www.npmjs.com/package/okengine)
13
- [![JSR](https://jsr.io/badges/@omqkhafi/okengine)](https://jsr.io/@omqkhafi/okengine)
14
- [![npm](https://img.shields.io/npm/v/create-oke.svg)](https://www.npmjs.com/package/create-oke)
15
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
16
- [![Bun >=1.3](https://img.shields.io/badge/Bun-%3E%3D1.3-black.svg)](https://bun.sh)
78
+ <p align="center">
79
+ <em>Stop gluing APIs, jobs, and queues into one backend. One law collapses them — client, Console, and infra come free. Yours to host.</em>
80
+ </p>
17
81
 
18
- **Docs:** [oke.omqkhafi.dev](https://oke.omqkhafi.dev) · **Handbook:** [/docs](https://oke.omqkhafi.dev/docs) · **CLI:** `oke` · **Scaffold:** [`create-oke`](https://www.npmjs.com/package/create-oke)
82
+ <p align="center">
83
+ <a href="https://oke.omqkhafi.dev"><strong>Docs</strong></a> ·
84
+ <a href="https://oke.omqkhafi.dev/docs"><strong>Handbook</strong></a> ·
85
+ <code>oke</code> CLI ·
86
+ <a href="https://www.npmjs.com/package/create-oke"><code>create-oke</code></a>
87
+ </p>
19
88
 
20
89
  > [!WARNING]
21
90
  > **Early development (`v0.x`) — not production-ready.**
@@ -27,13 +96,13 @@ _"Stop gluing APIs, jobs, and queues into one backend. One law collapses them
27
96
  ```bash
28
97
  bun add okengine # framework + `oke` CLI
29
98
  bunx jsr add @omqkhafi/okengine # JSR — library API only
30
- bunx create-oke@latest my-app --template hello # scaffold
99
+ bunx create-oke@latest my-app # scaffold
31
100
  ```
32
101
 
33
102
  ## Quick start
34
103
 
35
104
  ```bash
36
- bunx create-oke@latest my-app --template hello
105
+ bunx create-oke@latest my-app
37
106
  cd my-app
38
107
  oke dev
39
108
  # or: oke mode docker && oke dev # compose infra · app on host Bun
@@ -102,6 +171,72 @@ export const remove = mounted.remove;
102
171
 
103
172
  Full walkthrough: [Basic usage](https://oke.omqkhafi.dev/docs/get-started/basic-usage).
104
173
 
174
+ ## Charts
175
+
176
+ <p align="center">
177
+ <a href="https://github.com/omqkhafi/okengine/issues">
178
+ <picture>
179
+ <source
180
+ media="(prefers-color-scheme: dark)"
181
+ srcset="https://shieldcn.dev/chart/github/issues/omqkhafi/okengine.svg?theme=zinc&font=geist&mode=dark"
182
+ />
183
+ <img
184
+ alt="GitHub issues over time"
185
+ src="https://shieldcn.dev/chart/github/issues/omqkhafi/okengine.svg?theme=zinc&font=geist&mode=light"
186
+ width="750"
187
+ />
188
+ </picture>
189
+ </a>
190
+ </p>
191
+
192
+ <p align="center">
193
+ <a href="https://github.com/omqkhafi">
194
+ <picture>
195
+ <source
196
+ media="(prefers-color-scheme: dark)"
197
+ srcset="https://shieldcn.dev/chart/github/commits/omqkhafi.svg?theme=zinc&font=geist&mode=dark"
198
+ />
199
+ <img
200
+ alt="Lifetime commit history"
201
+ src="https://shieldcn.dev/chart/github/commits/omqkhafi.svg?theme=zinc&font=geist&mode=light"
202
+ width="750"
203
+ />
204
+ </picture>
205
+ </a>
206
+ </p>
207
+
208
+ <p align="center">
209
+ <a href="https://www.npmjs.com/package/okengine">
210
+ <picture>
211
+ <source
212
+ media="(prefers-color-scheme: dark)"
213
+ srcset="https://shieldcn.dev/chart/npm/okengine.svg?theme=zinc&font=geist&mode=dark"
214
+ />
215
+ <img
216
+ alt="okengine npm downloads"
217
+ src="https://shieldcn.dev/chart/npm/okengine.svg?theme=zinc&font=geist&mode=light"
218
+ width="750"
219
+ />
220
+ </picture>
221
+ </a>
222
+ </p>
223
+
224
+ <p align="center">
225
+ <a href="https://www.npmjs.com/package/create-oke">
226
+ <picture>
227
+ <source
228
+ media="(prefers-color-scheme: dark)"
229
+ srcset="https://shieldcn.dev/chart/npm/create-oke.svg?theme=zinc&font=geist&mode=dark"
230
+ />
231
+ <img
232
+ alt="create-oke npm downloads"
233
+ src="https://shieldcn.dev/chart/npm/create-oke.svg?theme=zinc&font=geist&mode=light"
234
+ width="750"
235
+ />
236
+ </picture>
237
+ </a>
238
+ </p>
239
+
105
240
  ## Documentation
106
241
 
107
242
  Everything beyond this page lives in the site handbook:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,7 +58,7 @@
58
58
  "prepack": "bun run build",
59
59
  "bench": "bun test ./src/runtime/cold-start.bench.ts src/client/budget.test.ts src/console/budget.test.ts src/kernel/budget.test.ts src/kernel/routing-budget.test.ts src/runs/bench.test.ts",
60
60
  "budgets": "bun src/release/publish.ts",
61
- "gate": "bun test src/cli/doc-staleness.test.ts src/cli/competitor-mention-removal.test.ts src/drivers/vault-driver-removal.test.ts src/kernel/errors.registry.test.ts src/upgrade/codemods.test.ts",
61
+ "gate": "PUBLISH_GATE=1 bun test src/cli/doc-staleness.test.ts src/cli/competitor-mention-removal.test.ts src/drivers/vault-driver-removal.test.ts src/kernel/errors.registry.test.ts src/upgrade/codemods.test.ts scripts/publish.gate.test.ts",
62
62
  "dev": "bun run --cwd site dev",
63
63
  "site:build": "bun run --cwd site build",
64
64
  "ci": "bun scripts/ci.ts",
@@ -71,8 +71,9 @@
71
71
  "@duckdb/node-api": "^1.5.5-r.2",
72
72
  "ajv": "^8.20.0",
73
73
  "ajv-formats": "^3.0.1",
74
+ "intl-messageformat": "^11.2.13",
74
75
  "oxc-parser": "^0.142.0",
75
- "sently": "^0.8.0"
76
+ "sently": "1.0.0"
76
77
  },
77
78
  "devDependencies": {
78
79
  "@base-ui/react": "^1.6.0",
@@ -88,6 +88,17 @@ export const triage = smart.prompt("ticket-triage", {
88
88
 
89
89
  <AiGuardrails />
90
90
 
91
+ ## Tools are flows (same `fx.call` path)
92
+
93
+ Offer flows as tools on a one-shot ask — each model-initiated call goes through the host flow’s `fx.call`, so capability, Manifest `calls`, and Runs stay honest. No silent extra authority for the model:
94
+
95
+ ```typescript
96
+ const result = await fx.ask(triage, input, {
97
+ tools: [getBooking, refundBooking],
98
+ maxSteps: 6,
99
+ });
100
+ ```
101
+
91
102
  ## Agents with real guardrails
92
103
 
93
104
  An agent's tools are your flows — each carrying its own gates, effects, and typed errors, so the agent can never do anything a flow couldn't:
@@ -101,7 +112,25 @@ export const support = ai.agent("support", {
101
112
  });
102
113
  ```
103
114
 
104
- `maxSteps` bounds the loop; `budget.maxCostPerRun` bounds the spend. Both are declared, so "the agent ran away" is a violated contract, not a surprise.
115
+ `maxSteps` bounds the loop; `budget.maxCostPerRun` bounds the spend. Both are declared, so "the agent ran away" is a violated contract, not a surprise. `fx.run(support, { message })` uses the same tool loop and the same `fx.call` dispatch as `fx.ask(…, { tools })`.
116
+
117
+ ## Rate limits via Gate
118
+
119
+ AI calls are expensive — reuse `gate.rate`, don’t invent a parallel budget. Presets: `aiRateGate("ask" | "agent" | "embed")` (defaults 20 / 10 / 60 per minute, `keyBy: "user"`). Attach on the HTTP trigger:
120
+
121
+ ```typescript
122
+ on(http.post("/triage").gate(member, aiRateGate("ask")), triageFlow);
123
+ ```
124
+
125
+ Use `keyBy: "ip"` on public unauthenticated AI edges. Cost caps stay on prompt/agent `budget` decls.
126
+
127
+ ## Secrets stay out of prompts
128
+
129
+ `Redacted` values (e.g. from `fx.vault`) stringify to `[redacted]` in provider-facing messages — the same placeholder as logs. Never cleartext to a model.
130
+
131
+ ## Streaming
132
+
133
+ `fx.stream(model, { prompt })` yields real provider tokens (Ollama NDJSON, OpenAI-compatible SSE). Cancel by aborting the ambient signal — the same one `fx.all` / `fx.race` already use. Drivers without `stream` fail loud (no stub echo).
105
134
 
106
135
  ## PII cannot leak by accident
107
136
 
@@ -130,6 +159,53 @@ Because `fx.ask` is nondeterministic, the runtime adjusts around it: journaling
130
159
 
131
160
  There is deliberately **no production default**: `prod` must name a driver, which keeps the model choice visible in `oke.config.ts` where review can see it.
132
161
 
162
+ ### Local Ollama
163
+
164
+ `ollama` is a local-model driver — any model you have pulled works. Pin it in `drivers.ai`; optionally pin `images.ai` for compose.
165
+
166
+ ```typescript title="oke.config.ts"
167
+ import { defineConfig } from "okengine/config";
168
+
169
+ export default defineConfig({
170
+ drivers: {
171
+ ai: { local: "ollama", docker: "ollama", prod: "anthropic" },
172
+ },
173
+ images: {
174
+ ai: "ollama/ollama:latest",
175
+ },
176
+ });
177
+ ```
178
+
179
+ ```typescript title="src/ai.ts"
180
+ export const smart = ai.model("smart", {
181
+ provider: "ollama",
182
+ model: "qwen3.5:9b", // balanced local-dev starting point — override freely
183
+ });
184
+ ```
185
+
186
+ - **Model:** set on `ai.model(…, { model })` or `OKE_AI_MODEL`. `qwen3.5:9b` ([Ollama library](https://ollama.com/library/qwen3.5); same as `qwen3.5:latest`) is a reasonable balanced starting point for local dev — not the only option. On Apple Silicon, prefer `qwen3.5:9b-mlx` for the MLX build.
187
+ - **URL:** default `http://127.0.0.1:11434`. Override with `OKE_AI_URL` or `OLLAMA_HOST` (bare `host:port` is fine).
188
+ - **Docker:** the image recipe pulls the configured model and writes `OKE_AI_URL` into `docker/.env.docker`.
189
+ - **Fail-loud:** unreachable or unhealthy Ollama throws `OllamaUnavailableError` — never a silent mock fallback.
190
+ - **PII:** `ollama` counts as on-premise for the build-time egress check (same as `mock`).
191
+
192
+ ### OpenAI-compatible endpoints (one driver)
193
+
194
+ `openai-compatible` is the shared chat/completions client — OpenAI, Groq, Together, OpenRouter, vLLM, LM Studio, and Ollama’s `/v1` shim. You do **not** get a separate driver per vendor; only `baseUrl`, `apiKey`, `model`, and optional `headers` change.
195
+
196
+ | Target | Typical `baseUrl` | Key |
197
+ | ------------------ | ------------------------------------- | ----------------------------------------------------------- |
198
+ | OpenAI | `https://api.openai.com/v1` (default) | required (`OPENAI_API_KEY`) |
199
+ | Groq | `https://api.groq.com/openai/v1` | required |
200
+ | Together | `https://api.together.xyz/v1` | required |
201
+ | OpenRouter | `https://openrouter.ai/api/v1` | required; optional `HTTP-Referer` / `X-Title` via `headers` |
202
+ | LM Studio / local | e.g. `http://127.0.0.1:1234/v1` | optional |
203
+ | Ollama OpenAI shim | `http://127.0.0.1:11434/v1` | optional |
204
+
205
+ Prefer the native `ollama` driver for local Ollama (`/api/chat`). Point `openai-compatible` at Ollama `/v1` only when you need the OpenAI wire shape.
206
+
207
+ **Consequence:** a missing key against the default OpenAI cloud base fails at open; a custom `baseUrl` may omit the key. HTTP errors always throw — never a silent mock fallback.
208
+
133
209
  ## Troubleshooting
134
210
 
135
211
  <Accordions>
@@ -142,6 +218,11 @@ A flow sends a classified PII field to a third-party model. Either stop sending
142
218
 
143
219
  The one you declared. There is no fallback or guess — if `prod` has no AI driver configured, that's a configuration gap to fix, not a silent default.
144
220
 
221
+ </Accordion>
222
+ <Accordion title="oke boot: ollama driver needs OKE_AI_URL">
223
+
224
+ Docker mode expects `OKE_AI_URL` from `docker/.env.docker` (written when `images.ai` is pinned). Locally, the default is `http://127.0.0.1:11434`. If asks fail with `OllamaUnavailableError`, confirm the server answers and that you have pulled the model you named (`ollama pull qwen3.5:9b`, or `qwen3.5:9b-mlx` on Apple Silicon, or your `OKE_AI_MODEL`).
225
+
145
226
  </Accordion>
146
227
  <Accordion title="A prompt edit made answers worse — how do I catch that pre-merge?">
147
228
 
@@ -87,7 +87,12 @@ Opt-out is first-class: a subject who opted out of a medium is **suppressed**
87
87
 
88
88
  ### Locale resolves through a chain
89
89
 
90
- Templates render per recipient locale, falling back through your configured chain (`ar` → default `en`) instead of failing when a translation is missing. Locales and the default come from the `i18n` block in `oke.config.ts`.
90
+ Templates render per recipient locale, falling back through your configured chain
91
+ (`ar` → default `en`) instead of failing when a translation is missing.
92
+
93
+ Locales come from `oke.config.ts` `i18n`. Catalog bodies use `{{field}}` — not
94
+ ICU ([i18n](/docs/reference/i18n) / `fx.t` owns flow copy). Pass `locale` /
95
+ `profileLocale` / `acceptLanguage` on `fx.send`, or omit them for `fx.locale`.
91
96
 
92
97
  ### Fallback chains are explicit
93
98
 
@@ -104,23 +109,52 @@ Each send records its attempts — driver, ok/error, timestamp, message id — s
104
109
 
105
110
  ## Per-environment drivers
106
111
 
112
+ Boot opens the email driver from `drivers.channel.email`, and appends an SMS
113
+ driver when `drivers.channel.sms` is set (other than `console`). WhatsApp and
114
+ push drivers are passed via `BootOptions.channel.drivers` when you need them.
115
+
107
116
  ```typescript title="oke.config.ts"
108
117
  drivers: {
109
118
  channel: {
110
119
  email: { local: "console", docker: "smtp", test: "console", prod: "smtp" },
120
+ // optional — omit until you need SMS at boot
121
+ sms: { local: "console", docker: "taqnyat", test: "console", prod: "taqnyat" },
111
122
  },
112
123
  },
113
124
  images: {
114
- "channel.email": "axllent/mailpit:v1.22.3", // SMTP catcher for the docker stack
125
+ "channel.email": "axllent/mailpit:v1.22.3",
115
126
  },
116
127
  ```
117
128
 
118
- | Driver | Medium | Behavior |
119
- | ---------- | ------ | ---------------------------------------------------- |
120
- | `console` | any | Captures into a readable inbox — local + tests |
121
- | `smtp` | email | Real SMTP — Mailpit in docker, your provider in prod |
122
- | `resend` | email | Resend API |
123
- | `unifonic` | sms | Unifonic SMS API |
129
+ | Driver | Medium | Behavior |
130
+ | ---------- | -------- | ------------------------------------------------------------------- |
131
+ | `console` | any | Captures into a readable inbox — local + tests |
132
+ | `smtp` | email | Real SMTP — Mailpit in docker, your provider in prod |
133
+ | `resend` | email | Resend HTTP API (`RESEND_API_KEY`) |
134
+ | `sndr` | email | SNDR HTTP API (`SNDR_API_KEY`) |
135
+ | `taqnyat` | sms | Taqnyat SMS (`TAQNYAT_BEARER_TOKEN` + `TAQNYAT_SENDER`) |
136
+ | `msegat` | sms | Msegat SMS (`MSEGAT_USERNAME` + `MSEGAT_API_KEY` + `MSEGAT_SENDER`) |
137
+ | `unifonic` | sms | Unifonic el.cloud SMS (`UNIFONIC_APPSID`) |
138
+ | `wa-cloud` | whatsapp | Meta WhatsApp Cloud API |
139
+ | `webpush` | push | Web Push + VAPID (`createPushSender` → `WebPushTransport`) |
140
+ | `fcm` | push | Firebase Cloud Messaging HTTP v1 |
141
+
142
+ Email fallback uses ordered email transports; SMS with multiple SMS drivers uses
143
+ the same failover path. Credentials for boot-opened drivers are listed under
144
+ [Environment Variables](/docs/reference/environment-variables).
145
+
146
+ `webpush` needs VAPID keys — open it yourself and include it in
147
+ `BootOptions.channel.drivers` (boot does not open push from env):
148
+
149
+ ```typescript
150
+ import { openWebPushChannel } from "okengine/drivers/channel-webpush";
151
+
152
+ openWebPushChannel({
153
+ vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,
154
+ vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,
155
+ vapidSubject: process.env.VAPID_SUBJECT,
156
+ });
157
+ ```
124
158
 
125
159
  ## Troubleshooting
126
160
 
@@ -139,11 +173,44 @@ Check consent first: if they opted out, sends to them are suppressed by design.
139
173
 
140
174
  Locale resolution falls back through the chain to your default locale when a translation is missing — the send still succeeds with the fallback body. Check that the template declares `locales: ["en", "ar"]` and that the Arabic body exists in the catalog.
141
175
 
176
+ </Accordion>
177
+ <Accordion title="How do I ingest SNDR / Unifonic delivery webhooks?">
178
+
179
+ ```typescript
180
+ import {
181
+ parseSndrWebhook,
182
+ verifySndrSignature,
183
+ parseUnifonicWebhook,
184
+ toDeliveryEvent,
185
+ } from "okengine/channel";
186
+ ```
187
+
188
+ Verify the signature when the provider sends one, parse the body, then call
189
+ `ingestOutcome` on your channel runtime with the bounce/complaint fields.
190
+ Console reads the receipt ledger — never raw webhook JSON.
191
+
142
192
  </Accordion>
143
193
  <Accordion title="How do I know which medium finally delivered?">
144
194
 
145
195
  The receipt keeps every attempt in order with its outcome. A send that succeeded on a later medium reports status `fallback` — you can see the full chain in Console → Channels.
146
196
 
197
+ </Accordion>
198
+ <Accordion title="How do I verify Web Push against a real browser?">
199
+
200
+ Subscribe in a real browser with sently’s `examples/webpush-interop` page
201
+ (paste the same `VAPID_PUBLIC_KEY`), copy the `PushSubscription` JSON, then:
202
+
203
+ ```sh
204
+ export VAPID_PUBLIC_KEY='…'
205
+ export VAPID_PRIVATE_KEY='…'
206
+ bun scripts/webpush-interop.ts /tmp/push-sub.json
207
+ ```
208
+
209
+ That path is Channel → `webpush` → sently `createPushSender` →
210
+ `WebPushTransport`, and it prints the push-service HTTP response.
211
+
212
+ Confirm a visible OS notification titled `okengine webpush interop`.
213
+
147
214
  </Accordion>
148
215
  </Accordions>
149
216
 
@@ -151,6 +218,8 @@ The receipt keeps every attempt in order with its outcome. A send that succeeded
151
218
 
152
219
  - [Flow](/docs/elements/flow) — `fx.send` inside `do`
153
220
  - [Console · Channels](/docs/console/channels) — receipts, attempts, suppression
221
+ - [Environment Variables](/docs/reference/environment-variables) — Channel boot credentials
222
+ - [Configuration](/docs/reference/configuration) — `drivers.channel.*` maps
154
223
  - [Signal](/docs/elements/signal) — machine-to-machine messaging, the other side of the line
155
224
 
156
225
  ## Next
@@ -92,7 +92,9 @@ do: async (input, fx) => {
92
92
  };
93
93
  ```
94
94
 
95
- Every response follows one envelope — success `{ data, error: null }`, failure `{ data: null, error: { code, data } }` — so clients handle outcomes by `error.code`, not by parsing status text.
95
+ Every response follows one envelope — success `{ data, error: null }`, failure
96
+ `{ data: null, error: { code, data, message? } }` — clients switch on `error.code`.
97
+ Built-in codes attach a localized `message` ([Errors](/docs/reference/errors)); custom codes stay message-less unless registered or `{ message }` is passed.
96
98
 
97
99
  ## The five triggers
98
100
 
@@ -178,22 +180,22 @@ const order = await fx.call(getOrder, { id: orderId }); // from any other flow
178
180
 
179
181
  Everything a flow may touch, on one object:
180
182
 
181
- | Surface | Effect recorded | What it does |
182
- | --------------------------------------- | --------------- | -------------------------------------------- |
183
- | `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
184
- | `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
185
- | `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
186
- | `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
187
- | `fx.run(agent, input)` | ask | Run a bounded agent |
188
- | `fx.call(flow, input)` | call | Invoke another flow |
189
- | `fx.vault(contract)` | read | Read a secret (`Redacted`; logs masked) |
190
- | `fx.clock.now()` / `.sleep(…)` | — | Injected time / durable sleep |
191
- | `fx.cache.get/set` | — | Shared cache with effect-aware invalidation |
192
- | `fx.step(name, fn)` | — | Named durable step — never re-runs on replay |
193
- | `fx.all` / `fx.race` / `fx.retry` | — | Structured concurrency + backoff retry |
194
- | `fx.signal` | — | Ambient `AbortSignal` for the current branch |
195
- | `fx.id()` · `fx.log` · `fx.t` | — | UUIDs, redacting logger, i18n |
196
- | `fx.auth` · `fx.operator` · `fx.tenant` | — | Who is calling (user / operator / tenant) |
183
+ | Surface | Effect recorded | What it does |
184
+ | ------------------------------------------- | --------------- | -------------------------------------------- |
185
+ | `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
186
+ | `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
187
+ | `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
188
+ | `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
189
+ | `fx.run(agent, input)` | ask | Run a bounded agent |
190
+ | `fx.call(flow, input)` | call | Invoke another flow |
191
+ | `fx.vault(contract)` | read | Read a secret (`Redacted`; logs masked) |
192
+ | `fx.clock.now()` / `.sleep(…)` | — | Injected time / durable sleep |
193
+ | `fx.cache.get/set` | — | Shared cache with effect-aware invalidation |
194
+ | `fx.step(name, fn)` | — | Named durable step — never re-runs on replay |
195
+ | `fx.all` / `fx.race` / `fx.retry` | — | Structured concurrency + backoff retry |
196
+ | `fx.signal` | — | Ambient `AbortSignal` for the current branch |
197
+ | `fx.id()` · `fx.log` · `fx.t` · `fx.locale` | — | UUIDs, redacting logger, ICU i18n |
198
+ | `fx.auth` · `fx.operator` · `fx.tenant` | — | Who is calling (user / operator / tenant) |
197
199
 
198
200
  <Callout title="Why this strictness pays off">
199
201
  Effects are inferred from `fx` usage, so the Manifest knows exactly which flows read `orders` or
@@ -275,6 +277,7 @@ Only that run fails — the schedule keeps firing and the process does not exit.
275
277
 
276
278
  ## Learn more
277
279
 
280
+ - [fx](/docs/reference/fx) — full `fx` surface, including ICU `fx.t` / `fx.locale`
278
281
  - [Signal](/docs/elements/signal) — delivery physics (`once` · `broadcast` · `live`)
279
282
  - [Clock](/docs/elements/clock) — schedules and durable sleep
280
283
  - [Console · Flows](/docs/console/flows) — the Manifest-derived panel
@@ -5,12 +5,13 @@ icon: "MailCheck"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
- `emailOtp()` stores a hashed 6-digit code (default 10 minutes, 5 attempts). Verify creates the
9
- user on first success and returns hybrid session tokens.
8
+ `emailOtp()` stores a hashed 6-digit code (default 10 minutes, 5 attempts). Request sends the
9
+ code via Channel (`auth-email-otp`); verify creates the user on first success and returns hybrid
10
+ session tokens.
10
11
 
11
12
  <Callout title="The one rule">
12
- Enable `gate.auth`, then `.plug(emailOtp())`. Never log raw OTPs. Channel email delivery is not
13
- wired yet — use `exposeDevOtp` locally.
13
+ Enable `gate.auth`, then `.plug(emailOtp())`. Never log raw OTPs. Delivery goes through `fx.send`;
14
+ use `exposeDevOtp` only for local DX without SMTP.
14
15
  </Callout>
15
16
 
16
17
  ## Quick start
@@ -28,7 +29,7 @@ export const app = oke({
28
29
  name: "shop",
29
30
  env: "local",
30
31
  gate: { auth: {} },
31
- }).plug(emailOtp({ exposeDevOtp: true }));
32
+ }).plug(emailOtp());
32
33
  ```
33
34
 
34
35
  </Step>
@@ -38,7 +39,7 @@ export const app = oke({
38
39
 
39
40
  ```typescript
40
41
  const { data } = await api.auth.requestEmailOtp({ email: "ali@example.com" });
41
- // data.devOtp when exposeDevOtp
42
+ // Channel delivers auth-email-otp; data.devOtp only when exposeDevOtp
42
43
  ```
43
44
 
44
45
  `POST /auth/email-otp/request` — prior active codes for that email are invalidated.
@@ -51,7 +52,7 @@ const { data } = await api.auth.requestEmailOtp({ email: "ali@example.com" });
51
52
  ```typescript
52
53
  const { data } = await api.auth.verifyEmailOtp({
53
54
  email: "ali@example.com",
54
- otp: data!.devOtp!,
55
+ otp,
55
56
  });
56
57
  ```
57
58
 
@@ -64,14 +65,15 @@ const { data } = await api.auth.verifyEmailOtp({
64
65
 
65
66
  ## Options
66
67
 
67
- | Option | Type | Default | Meaning |
68
- | --------------- | ------------------- | -------- | --------------------------------------- |
69
- | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
70
- | `sessions` | `SessionStore` | active\* | Session store |
71
- | `ttlMs` | `number` | 10m | Challenge lifetime |
72
- | `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
73
- | `identities` | `IdentityStore` | new | Email user map |
74
- | `verifications` | `VerificationStore` | new | Challenge store |
68
+ | Option | Type | Default | Meaning |
69
+ | --------------- | ------------------- | -------------------------- | --------------------------------------- |
70
+ | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
71
+ | `sessions` | `SessionStore` | active\* | Session store |
72
+ | `ttlMs` | `number` | 10m | Challenge lifetime |
73
+ | `from` | `string` | `OKE <no-reply@oke.local>` | Template From address |
74
+ | `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
75
+ | `identities` | `IdentityStore` | new | Email → user map |
76
+ | `verifications` | `VerificationStore` | new | Challenge store |
75
77
 
76
78
  ## Surfaces
77
79
 
@@ -80,13 +82,16 @@ const { data } = await api.auth.verifyEmailOtp({
80
82
  | `auth.requestEmailOtp` | `POST /auth/email-otp/request` | `gate.public` + otp rate |
81
83
  | `auth.verifyEmailOtp` | `POST /auth/email-otp/verify` | `gate.public` + otp rate |
82
84
 
85
+ **Consequence:** the plugin contributes the `auth-email-otp` Channel template and EN/AR
86
+ catalog bodies (`{{otp}}`). Override copy by merging your own catalog at boot.
87
+
83
88
  ## Troubleshooting
84
89
 
85
90
  <Accordions>
86
91
  <Accordion title="No email with the code">
87
92
 
88
- v1 does not send mail. Use `exposeDevOtp` in local/test, or send the code via your own Channel
89
- Flow.
93
+ In `local` / `test` the `console` driver captures mail open the inbox, or run
94
+ `oke dev --docker` and check Mailpit. For unit tests without SMTP, set `exposeDevOtp: true`.
90
95
 
91
96
  </Accordion>
92
97
  <Accordion title="AuthFailed after a few tries">
@@ -99,7 +104,8 @@ Five failed attempts consume the challenge. Request a new OTP.
99
104
  ## Learn more
100
105
 
101
106
  - [Magic link](/docs/plugins/magic-link) — link instead of a code
102
- - [Phone number](/docs/plugins/phone-number) — SMS OTP (E.164)
107
+ - [Phone number](/docs/plugins/phone-number) — SMS OTP (E.164; delivery deferred)
108
+ - [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
103
109
  - [Gate](/docs/elements/gate) — `gate.auth`
104
110
 
105
111
  ## Next
@@ -107,5 +113,5 @@ Five failed attempts consume the challenge. Request a new OTP.
107
113
  <Cards>
108
114
  <Card title="Phone number" description="E.164 SMS OTP." href="/docs/plugins/phone-number" />
109
115
  <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
110
- <Card title="Two-factor" description="TOTP step-up." href="/docs/plugins/two-factor" />
116
+ <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
111
117
  </Cards>