okengine 0.13.0 → 0.14.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.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +54 -1
- package/src/compiler/extract.ts +69 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +28 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
package/manifest.v1.schema.json
CHANGED
|
@@ -111,6 +111,11 @@
|
|
|
111
111
|
"type": "string",
|
|
112
112
|
"pattern": "^(sql|kv|files|index):.+$"
|
|
113
113
|
},
|
|
114
|
+
"SignalResourceRef": {
|
|
115
|
+
"description": "Dead-letter read via fx.deadLetters — signal:<name>, not a store facet.",
|
|
116
|
+
"type": "string",
|
|
117
|
+
"pattern": "^signal:.+$"
|
|
118
|
+
},
|
|
114
119
|
"SignalRef": {
|
|
115
120
|
"type": "string",
|
|
116
121
|
"minLength": 1
|
|
@@ -145,7 +150,8 @@
|
|
|
145
150
|
{
|
|
146
151
|
"const": "runs",
|
|
147
152
|
"description": "Runs wide-event store via fx.runs (observability read)."
|
|
148
|
-
}
|
|
153
|
+
},
|
|
154
|
+
{ "$ref": "#/$defs/SignalResourceRef" }
|
|
149
155
|
]
|
|
150
156
|
},
|
|
151
157
|
"uniqueItems": true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": {
|
|
@@ -271,6 +271,6 @@
|
|
|
271
271
|
"react": "^19.2.8"
|
|
272
272
|
},
|
|
273
273
|
"engines": {
|
|
274
|
-
"bun": ">=1.
|
|
274
|
+
"bun": ">=1.4.0"
|
|
275
275
|
}
|
|
276
276
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "MCP"
|
|
3
|
-
description: "
|
|
3
|
+
description: "OKE serves MCP on :6535 so agents can operate a running app, and apps consume external MCP servers as allowlisted tools on the same fx.call path."
|
|
4
4
|
icon: "Plug"
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Two directions, same protocol. **Serve:** OKE exposes your running app on port **6535** so an agent can read the Manifest and take confirmed actions. **Consume:** your Flows call _external_ MCP servers as tools — declared on `ai.mcpServer`, dispatched through `fx.call`.
|
|
9
|
+
|
|
10
|
+
The server on **6535** speaks JSON-RPC over HTTP (MCP protocol `2024-11-05`), requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead.
|
|
9
11
|
|
|
10
12
|
<Callout title="The one rule">
|
|
11
13
|
MCP inherits the operator's capability and can never exceed it. Server-level controls alone are
|
|
@@ -96,6 +98,29 @@ Consumed tokens cannot be replayed; the next write needs a new confirmation. A m
|
|
|
96
98
|
|
|
97
99
|
The docs content ships inside the `okengine` package, so the index your agent searches is exactly the version you have installed. If the surface cannot boot (missing content, busy port), `oke dev` prints `Docs MCP skipped — …` and continues — docs search never takes your dev session down.
|
|
98
100
|
|
|
101
|
+
## Consume — `ai.mcpServer`
|
|
102
|
+
|
|
103
|
+
Your app can _call_ other MCP servers. Those tools are not a second loop — they join `fx.ask` / `ai.agent` the same way a Flow tool does.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
export const github = ai.mcpServer("github", {
|
|
107
|
+
url: "https://mcp.example/github",
|
|
108
|
+
auth: { bearer: githubToken },
|
|
109
|
+
tools: ["create_issue"], // required allowlist
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await fx.ask(triage, input, { tools: [github.tool("create_issue")] });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Rule | Meaning |
|
|
116
|
+
| ---------- | ------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| Allowlist | `tools` is required. Extra names from `tools/list` are dropped. |
|
|
118
|
+
| Capability | `mcp:<server>/<tool>` on `effects.calls` — undeclared throws **OKE1007**. |
|
|
119
|
+
| Transport | `url` (Streamable HTTP) **or** `command` + `args` (stdio). Not both. |
|
|
120
|
+
| Cancel | HTTP aborts the fetch / SSE stream. stdio sends `notifications/cancelled` then kills the process. |
|
|
121
|
+
|
|
122
|
+
Console draws each declared server as one **AI** node on the flow graph; Units chips read `Call github → create_issue`; traces label the effect **MCP call**. There is no separate MCP page or connect UI.
|
|
123
|
+
|
|
99
124
|
## Learn more
|
|
100
125
|
|
|
101
126
|
- [Agent contracts](/docs/ai/skills) — what agents are taught about the system they operate
|
|
@@ -68,14 +68,15 @@ export const triage = smart.prompt("ticket-triage", {
|
|
|
68
68
|
|
|
69
69
|
</Steps>
|
|
70
70
|
|
|
71
|
-
## The
|
|
71
|
+
## The building blocks
|
|
72
72
|
|
|
73
|
-
| Declaration | Produces
|
|
74
|
-
| -------------------------- |
|
|
75
|
-
| `ai.model(name, opts)` | Logical model binding — provider / tier /
|
|
76
|
-
| `model.prompt(name, opts)` | Versioned prompt with typed in/out, evals, budget
|
|
77
|
-
| `ai.embed(name, opts)` | Embedding pipeline into a `store.index` (searched via `fx.search`)
|
|
78
|
-
| `ai.agent(name, opts)` | Bounded agent whose tools are **your own flows**
|
|
73
|
+
| Declaration | Produces |
|
|
74
|
+
| -------------------------- | ------------------------------------------------------------------------------- |
|
|
75
|
+
| `ai.model(name, opts)` | Logical model binding — provider / tier / model id / optional `driverId` |
|
|
76
|
+
| `model.prompt(name, opts)` | Versioned prompt with typed in/out, evals, budget |
|
|
77
|
+
| `ai.embed(name, opts)` | Embedding pipeline into a `store.index` (searched via `fx.search`) |
|
|
78
|
+
| `ai.agent(name, opts)` | Bounded agent whose tools are **your own flows** |
|
|
79
|
+
| `ai.mcpServer(name, opts)` | External MCP server — allowlisted tools join `fx.call` as `mcp:<server>/<tool>` |
|
|
79
80
|
|
|
80
81
|
<AiBlocks />
|
|
81
82
|
|
|
@@ -105,9 +106,11 @@ const out = await fx.ask(summarizeNote, { title, body });
|
|
|
105
106
|
// out.via is the logical model that answered
|
|
106
107
|
```
|
|
107
108
|
|
|
108
|
-
Resolution: `fx.ask(…, { via })` overrides `prompt.via`, else the prompt’s bound model. On each model the runtime retries **once** for retryable failures (timeout / 429 / 5xx / network), then advances.
|
|
109
|
+
Resolution: `fx.ask(…, { via })` overrides `prompt.via`, else the prompt’s bound model. On each model the runtime retries **once** for retryable failures (timeout / 429 / 5xx / network), then advances.
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
Permanent failures (401 / other 4xx / schema invalid / client disconnect / `maxCostPerCall` exceeded) stop the chain. When every eligible attempt fails, `fx.ask` throws — map that to a typed error in the flow (no silent text excerpt).
|
|
112
|
+
|
|
113
|
+
`timeout` uses the same duration vocabulary as Clock (`"30s"`, `"2m"`, or a millisecond number). Ask-time `fx.ask(…, { timeout: "10s" })` overrides the prompt. Omit = no artificial cap. `budget.maxCostPerCall` is enforced at runtime (not just observed). Set `driverId` on a model to open a different protocol than the app default.
|
|
111
114
|
|
|
112
115
|
## Declared guardrails
|
|
113
116
|
|
|
@@ -126,6 +129,22 @@ const result = await fx.ask(triage, input, {
|
|
|
126
129
|
});
|
|
127
130
|
```
|
|
128
131
|
|
|
132
|
+
External MCP tools take the same path. Declare a server with a **required** allowlist, then pass `server.tool("…")` — never whatever `tools/list` happens to expose:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
export const github = ai.mcpServer("github", {
|
|
136
|
+
url: "https://mcp.example/github",
|
|
137
|
+
auth: { bearer: githubToken },
|
|
138
|
+
tools: ["create_issue"],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await fx.ask(triage, input, {
|
|
142
|
+
tools: [github.tool("create_issue")],
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Capability refs are `mcp:<server>/<tool>`. An undeclared call throws **OKE1007**. See [MCP](/docs/ai/mcp).
|
|
147
|
+
|
|
129
148
|
## Agents with real guardrails
|
|
130
149
|
|
|
131
150
|
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:
|
|
@@ -139,7 +158,7 @@ export const support = ai.agent("support", {
|
|
|
139
158
|
});
|
|
140
159
|
```
|
|
141
160
|
|
|
142
|
-
`maxSteps` bounds the loop; `budget.maxCostPerRun`
|
|
161
|
+
`maxSteps` bounds the loop; `budget.maxCostPerRun` is enforced after the run. 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 })`.
|
|
143
162
|
|
|
144
163
|
## Rate limits via Gate
|
|
145
164
|
|
|
@@ -157,7 +176,20 @@ Use `keyBy: "ip"` on public unauthenticated AI edges. Cost caps stay on prompt/a
|
|
|
157
176
|
|
|
158
177
|
## Streaming
|
|
159
178
|
|
|
160
|
-
`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).
|
|
179
|
+
`fx.stream(model, { prompt, via? })` 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).
|
|
180
|
+
|
|
181
|
+
Return those chunks on HTTP with `fx.json.stream(...)`. The kernel answers `text/event-stream` (`data:` frames, then `data: [DONE]`). JSON flows stay buffered `{ data, error }`.
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
on(
|
|
185
|
+
http.post("/complete").gate.public,
|
|
186
|
+
flow("chat.complete", {
|
|
187
|
+
do: (input, fx) => fx.json.stream(fx.stream(smart, { prompt: input.prompt })),
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Disconnect cancels the provider call automatically (`request.signal` → ambient abort). An `AbortError` from hang-up does not retry or advance `via`. Console Traces stay empty until the stream closes — then the run is one completed row whose duration covers the open stream, not just time-to-first-byte.
|
|
161
193
|
|
|
162
194
|
## PII cannot leak by accident
|
|
163
195
|
|
|
@@ -305,6 +337,11 @@ Capability pins and Call API pass `name@version`. `fx.ask` resolves the bare
|
|
|
305
337
|
prompt id. A pin that does not match `prompt.version` throws
|
|
306
338
|
`ai: unknown prompt "name@version"`.
|
|
307
339
|
|
|
340
|
+
</Accordion>
|
|
341
|
+
<Accordion title="OKE1007 when the model calls an MCP tool">
|
|
342
|
+
|
|
343
|
+
Add `mcp:<server>/<tool>` to that flow’s `effects.calls` — the same token as `fx.call`. The allowlist on `ai.mcpServer` is not a capability grant.
|
|
344
|
+
|
|
308
345
|
</Accordion>
|
|
309
346
|
<Accordion title="An agent looped and burned budget">
|
|
310
347
|
|
|
@@ -313,6 +350,15 @@ Bound it at declaration: `maxSteps` caps iterations, `budget.maxCostPerRun` caps
|
|
|
313
350
|
`fx.ask` stamps `promptVersion` on the run. Cost is recorded only when the
|
|
314
351
|
driver reports `usage.cost` — tokens land on the ask journal either way.
|
|
315
352
|
|
|
353
|
+
</Accordion>
|
|
354
|
+
<Accordion title="The client hung up but the model kept generating">
|
|
355
|
+
|
|
356
|
+
HTTP flows install `request.signal` as the ambient abort. `fx.ask` / `fx.stream` /
|
|
357
|
+
`fx.run` cancel the provider fetch. Return tokens with `fx.json.stream(fx.stream(...))`
|
|
358
|
+
so disconnect is observable.
|
|
359
|
+
|
|
360
|
+
A hang-up is `AbortError` — it does not retry or advance `via`.
|
|
361
|
+
|
|
316
362
|
</Accordion>
|
|
317
363
|
</Accordions>
|
|
318
364
|
|
|
@@ -320,6 +366,7 @@ driver reports `usage.cost` — tokens land on the ask journal either way.
|
|
|
320
366
|
|
|
321
367
|
- [Flow](/docs/elements/flow) — `fx.ask` and `fx.search` inside `do`
|
|
322
368
|
- [Store](/docs/elements/store) — `store.index`, the home of embeddings
|
|
369
|
+
- [MCP](/docs/ai/mcp) — serve the app to agents, or consume external servers as tools
|
|
323
370
|
|
|
324
371
|
## Next
|
|
325
372
|
|
|
@@ -28,7 +28,7 @@ export const purgeOld = on(
|
|
|
28
28
|
every("1h"),
|
|
29
29
|
flow("links.purgeOld", {
|
|
30
30
|
do: async (_, fx) => {
|
|
31
|
-
const cutoff = fx.clock.
|
|
31
|
+
const cutoff = fx.clock.ago("30d");
|
|
32
32
|
await fx.store(db).delete(links).where(lt(links.createdAt, cutoff));
|
|
33
33
|
},
|
|
34
34
|
}),
|
|
@@ -68,12 +68,15 @@ export const sendDaily = on(
|
|
|
68
68
|
<Step>
|
|
69
69
|
### Ask for time inside flows
|
|
70
70
|
|
|
71
|
-
`fx.clock` is the only clock a flow knows
|
|
71
|
+
`fx.clock` is the only clock a flow knows. Instants stay epoch-ms; spans use the same
|
|
72
|
+
duration strings as `every()` and `sleep()`:
|
|
72
73
|
|
|
73
74
|
```typescript
|
|
74
75
|
do: async (input, fx) => {
|
|
75
|
-
const
|
|
76
|
-
|
|
76
|
+
const cutoff = fx.clock.ago("30d");
|
|
77
|
+
const expiresAt = fx.clock.fromNow("14d");
|
|
78
|
+
const window = input.createdAt + fx.clock.duration("7d");
|
|
79
|
+
await fx.clock.sleep("wait-for-payment", "7d");
|
|
77
80
|
};
|
|
78
81
|
```
|
|
79
82
|
|
|
@@ -90,13 +93,25 @@ difference.
|
|
|
90
93
|
|
|
91
94
|
### `clock()` options
|
|
92
95
|
|
|
93
|
-
| Option | Type | Default | Meaning
|
|
94
|
-
| ------------- | ------- | ------- |
|
|
95
|
-
| `cron` | string | — |
|
|
96
|
-
| `every` | string | — | Fixed interval: `"30s"` · `"10m"` · `"1h"` · `"7d"`
|
|
97
|
-
| `timezone` | string | `"UTC"` | IANA timezone for cron evaluation
|
|
98
|
-
| `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate)
|
|
99
|
-
| `description` | string | — | Human title in the Console (falls back to the clock name)
|
|
96
|
+
| Option | Type | Default | Meaning |
|
|
97
|
+
| ------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
98
|
+
| `cron` | string | — | Five-field crontab (`m h dom mon dow`), plus steps, ranges, lists, names, `@hourly`, `@daily` (this or `every` required) |
|
|
99
|
+
| `every` | string | — | Fixed interval: `"30s"` · `"10m"` · `"1h"` · `"7d"` |
|
|
100
|
+
| `timezone` | string | `"UTC"` | IANA timezone for cron evaluation |
|
|
101
|
+
| `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate) |
|
|
102
|
+
| `description` | string | — | Human title in the Console (falls back to the clock name) |
|
|
103
|
+
|
|
104
|
+
### `fx.clock`
|
|
105
|
+
|
|
106
|
+
| Call | Returns | Meaning |
|
|
107
|
+
| -------------------- | ------- | ------------------------------------------------ |
|
|
108
|
+
| `now()` | instant | Injected epoch-ms |
|
|
109
|
+
| `ago("30d")` | instant | now − duration |
|
|
110
|
+
| `fromNow("14d")` | instant | now + duration |
|
|
111
|
+
| `duration("7d")` | span ms | Offset a stored instant (`createdAt + duration`) |
|
|
112
|
+
| `sleep(label, "7d")` | — | Durable sleep |
|
|
113
|
+
|
|
114
|
+
Same duration strings as `every()`: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400_000 ms, not a calendar day. Unknown strings parse as `0`.
|
|
100
115
|
|
|
101
116
|
## Sleeping inside a flow
|
|
102
117
|
|
|
@@ -136,7 +151,7 @@ Simple daily crons (`M H * * *`) in a DST-observing zone can hit a **gap** (spri
|
|
|
136
151
|
|
|
137
152
|
Detection attaches `dstAmbiguity` on the cron row and the Console shows it. `oke doctor` does not check DST. The scheduler does not rewrite the expression.
|
|
138
153
|
|
|
139
|
-
**Consequence:**
|
|
154
|
+
**Consequence:** firing follows crontab. A spring gap runs at the next valid instant (shifted). A fall overlap fires **once** (first occurrence). Prefer UTC when a shifted spring fire would hurt.
|
|
140
155
|
|
|
141
156
|
## Per-environment drivers
|
|
142
157
|
|
|
@@ -130,7 +130,9 @@ export const findOrder = on(
|
|
|
130
130
|
|
|
131
131
|
### signal — another flow emits
|
|
132
132
|
|
|
133
|
-
The producer emits through `fx
|
|
133
|
+
The producer emits through `fx`. For `once` and `broadcast` the consumer is the same species —
|
|
134
|
+
`on(signal, flow)`, no `subscribe()` registration. `live` replay is the exception: `bus.live()` on
|
|
135
|
+
the server, not a Flow.
|
|
134
136
|
|
|
135
137
|
```typescript
|
|
136
138
|
await fx.emit(orderPlaced, { orderId: id }); // inside the producing flow
|
|
@@ -154,7 +156,7 @@ on(
|
|
|
154
156
|
every("1h"),
|
|
155
157
|
flow("sessions.sweepExpired", {
|
|
156
158
|
do: async (_, fx) => {
|
|
157
|
-
const cutoff = fx.clock.
|
|
159
|
+
const cutoff = fx.clock.ago("30d");
|
|
158
160
|
await fx.store(db).delete(sessions).where(lt(sessions.createdAt, cutoff));
|
|
159
161
|
},
|
|
160
162
|
}),
|
|
@@ -198,12 +200,13 @@ Everything a flow may touch, on one object:
|
|
|
198
200
|
| ------------------------------------------- | --------------- | -------------------------------------------------- |
|
|
199
201
|
| `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
|
|
200
202
|
| `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
|
|
203
|
+
| `fx.deadLetters(signal)` | read | Dead-lettered messages for that signal |
|
|
201
204
|
| `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
|
|
202
205
|
| `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
|
|
203
206
|
| `fx.run(agent, input)` | ask | Run a bounded agent |
|
|
204
207
|
| `fx.call(flow, input)` | call | Invoke another flow |
|
|
205
208
|
| `fx.vault.get(contract)` | secret | Read a secret (`Redacted`; logs masked) |
|
|
206
|
-
| `fx.clock.now
|
|
209
|
+
| `fx.clock.now/ago/fromNow/duration/sleep` | — | Injected instants, spans, durable sleep |
|
|
207
210
|
| `fx.cache.get/set` | — | Shared cache with effect-aware invalidation |
|
|
208
211
|
| `fx.step(name, fn, { undo? })` | — | Named durable step — optional LIFO undo on failure |
|
|
209
212
|
| `fx.all` / `fx.race` / `fx.retry` | — | Structured concurrency + backoff retry |
|
|
@@ -71,7 +71,8 @@ export const sendConfirmation = on(
|
|
|
71
71
|
|
|
72
72
|
</Steps>
|
|
73
73
|
|
|
74
|
-
That's the loop
|
|
74
|
+
That's the loop for `once` and `broadcast`: declare → `fx.emit` → `on(signal, flow)`.
|
|
75
|
+
`live` keeps the same emit; the listener is `bus.live()`, not a Flow.
|
|
75
76
|
|
|
76
77
|
## The three delivery physics
|
|
77
78
|
|
|
@@ -96,7 +97,7 @@ One placed order can need all three physics at once — pick a signal per job, n
|
|
|
96
97
|
|
|
97
98
|
| Signal | Mode | Job |
|
|
98
99
|
| --------------- | ----------- | ----------------------------------------------------------- |
|
|
99
|
-
| `order-placed` | `once` |
|
|
100
|
+
| `order-placed` | `once` | One job — competing workers, retries, DLQ |
|
|
100
101
|
| `order-changed` | `broadcast` | Fan-out — cache invalidation **and** customer notification |
|
|
101
102
|
| `order-status` | `live` | Status feed — retained history a late subscriber can replay |
|
|
102
103
|
|
|
@@ -107,7 +108,7 @@ import { z } from "zod";
|
|
|
107
108
|
export const orderPlaced = signal("order-placed", {
|
|
108
109
|
schema: z.object({ orderId: z.string(), total: z.number() }),
|
|
109
110
|
delivery: "once",
|
|
110
|
-
retries:
|
|
111
|
+
retries: 3,
|
|
111
112
|
deadLetter: true,
|
|
112
113
|
});
|
|
113
114
|
|
|
@@ -126,25 +127,43 @@ export const orderStatus = signal("order-status", {
|
|
|
126
127
|
});
|
|
127
128
|
```
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
A Flow **writes** all three tapes. `once` / `broadcast` **listen** with `on(signal, flow)`. `live` does not.
|
|
130
131
|
|
|
131
|
-
```typescript title="src/flows/
|
|
132
|
-
on(
|
|
132
|
+
```typescript title="src/flows/orders/place.ts"
|
|
133
|
+
export const placeOrder = on(
|
|
134
|
+
http.post("/orders").gate.public,
|
|
135
|
+
flow("orders.place", {
|
|
136
|
+
in: z.object({ id: z.string(), total: z.number() }),
|
|
137
|
+
do: async (input, fx) => {
|
|
138
|
+
await fx.emit(orderPlaced, { orderId: input.id, total: input.total });
|
|
139
|
+
await fx.emit(orderChanged, { orderId: input.id, kind: "placed" });
|
|
140
|
+
await fx.emit(orderStatus, { orderId: input.id, status: "placed" });
|
|
141
|
+
await fx.emit(orderStatus, { orderId: input.id, status: "fulfilling" });
|
|
142
|
+
await fx.emit(orderStatus, { orderId: input.id, status: "shipped" });
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Stacked status emits are so replay is visible in one request. A real app emits as the order moves.
|
|
149
|
+
|
|
150
|
+
`once` — the listener is `on(orderPlaced, flow)`. Two workers compete; **exactly one** claims each message (at-least-once; make the handler idempotent):
|
|
151
|
+
|
|
152
|
+
```typescript title="src/flows/orders/send-confirmation.ts"
|
|
153
|
+
export const sendConfirmation = on(
|
|
133
154
|
orderPlaced,
|
|
134
|
-
flow("
|
|
135
|
-
do: async (
|
|
136
|
-
await fx.
|
|
137
|
-
await fx.emit(orderStatus, { orderId, status: "fulfilling" });
|
|
138
|
-
await fx.emit(orderStatus, { orderId, status: "shipped" });
|
|
155
|
+
flow("orders.sendConfirmation", {
|
|
156
|
+
do: async (input, fx) => {
|
|
157
|
+
await fx.send(orderConfirmed, { to: "user-1", data: input });
|
|
139
158
|
},
|
|
140
159
|
}),
|
|
141
160
|
);
|
|
142
161
|
```
|
|
143
162
|
|
|
144
|
-
`broadcast` — every
|
|
163
|
+
`broadcast` — every `on(orderChanged, flow)` gets its own copy. Neither Flow knows the other exists:
|
|
145
164
|
|
|
146
165
|
```typescript title="src/flows/orders/side-effects.ts"
|
|
147
|
-
on(
|
|
166
|
+
export const invalidateCache = on(
|
|
148
167
|
orderChanged,
|
|
149
168
|
flow("cache.onOrderChanged", {
|
|
150
169
|
do: async ({ orderId }, fx) => {
|
|
@@ -153,7 +172,7 @@ on(
|
|
|
153
172
|
}),
|
|
154
173
|
);
|
|
155
174
|
|
|
156
|
-
on(
|
|
175
|
+
export const notifyCustomer = on(
|
|
157
176
|
orderChanged,
|
|
158
177
|
flow("notify.onOrderChanged", {
|
|
159
178
|
do: async ({ orderId }, fx) => {
|
|
@@ -163,27 +182,38 @@ on(
|
|
|
163
182
|
);
|
|
164
183
|
```
|
|
165
184
|
|
|
166
|
-
`live` — retain
|
|
185
|
+
`live` — retain the tape. A late subscriber replays **full history** (`placed → fulfilling → shipped`), then keeps going.
|
|
167
186
|
|
|
168
|
-
Today that
|
|
187
|
+
The listener is **`bus.live()`**, not `on(orderStatus, flow)`. `on(signal, flow)` does not replay retained live payloads. Today that API is server-side; `createClient` has no SSE / WebSocket / `client.live` yet — see [Client](/docs/reference/client#signal-and-live-queries).
|
|
169
188
|
|
|
170
189
|
```typescript
|
|
171
|
-
|
|
190
|
+
const bus = app.bootResult?.signal?.bus;
|
|
191
|
+
if (!bus) throw new Error("signal runtime not booted");
|
|
172
192
|
|
|
173
|
-
// Late subscriber (server / test harness) receives placed → fulfilling → shipped.
|
|
174
193
|
const unsub = await bus.live("order-status", (payload) => {
|
|
175
|
-
/*
|
|
194
|
+
/* placed → fulfilling → shipped */
|
|
176
195
|
});
|
|
177
196
|
```
|
|
178
197
|
|
|
179
|
-
**Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the
|
|
198
|
+
**Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the timeline stays on `live`. Switching a word later is cheap; mixing physics on one name is not.
|
|
180
199
|
|
|
181
200
|
## When delivery fails
|
|
182
201
|
|
|
183
202
|
`once` signals retry automatically. Every attempt keeps a **typed failure reason**, and the full attempt history survives into the DLQ — so when a message lands there you see _why_ each attempt failed, not just that it did.
|
|
184
203
|
|
|
204
|
+
Query that queue from a Flow with `fx.deadLetters(signal)` — same handle as `fx.emit`. The compiler stamps `reads: ["signal:<name>"]`; another signal throws **OKE1001**.
|
|
205
|
+
|
|
185
206
|
The **Console** (`:6533` → `/overview`) shows emit and consume on the graph, and delivery attempts in Traces — no separate broker UI to run.
|
|
186
207
|
|
|
208
|
+
```typescript
|
|
209
|
+
on(
|
|
210
|
+
http.get("/notifications/failed"),
|
|
211
|
+
flow("notifications.failed", {
|
|
212
|
+
do: async (input, fx) => fx.json.withQuery(await fx.deadLetters(orderPlaced), input),
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
```
|
|
216
|
+
|
|
187
217
|
<Callout title="Orphan emits and schema fail loudly">
|
|
188
218
|
Zero subscribers throws **OKE1042** unless `optional: true`. A payload that fails the signal's
|
|
189
219
|
Standard Schema throws **OKE1043** before the message is staged — same `validate()` path as Flow
|
|
@@ -225,9 +255,10 @@ Committed `once` messages survive process death. A claim sets `lockedBy` and a v
|
|
|
225
255
|
`live` retains every delivered message and replays the **full history** to a late `bus.live()` subscriber. There is no TTL or max-count window on that retention today (the Console payload monitor shows only the newest 50 for display).
|
|
226
256
|
|
|
227
257
|
<Callout title="Client subscription is not shipped yet">
|
|
228
|
-
`delivery: "live"` means the **driver** retains and replays. `
|
|
229
|
-
|
|
230
|
-
|
|
258
|
+
`delivery: "live"` means the **driver** retains and replays. Listen with `bus.live()` on the
|
|
259
|
+
server — not `on(signal, flow)`. `createClient` has no SSE / WebSocket / `client.live` yet — poll
|
|
260
|
+
an HTTP Flow. See [Client · Signal and live
|
|
261
|
+
queries](/docs/reference/client#signal-and-live-queries).
|
|
231
262
|
</Callout>
|
|
232
263
|
|
|
233
264
|
## Orphaned signal config
|
|
@@ -258,7 +289,10 @@ Pairing an arbitrary Store insert with emit inside **one** shared SQL transactio
|
|
|
258
289
|
<Accordions>
|
|
259
290
|
<Accordion title="Emit fails with OKE1042 (no subscriber)">
|
|
260
291
|
|
|
261
|
-
You emitted a signal that nobody currently subscribes to.
|
|
292
|
+
You emitted a signal that nobody currently subscribes to. For `once` and `broadcast`, wire
|
|
293
|
+
`on(signal, flow)` before emit.
|
|
294
|
+
|
|
295
|
+
`live` listeners are `bus.live()`, not a Flow — set `optional: true` when they may connect later.
|
|
262
296
|
|
|
263
297
|
</Accordion>
|
|
264
298
|
<Accordion title="Emit fails with OKE1043 (schema)">
|
|
@@ -268,7 +302,18 @@ The payload failed the signal's Standard Schema at emit time — nothing was sta
|
|
|
268
302
|
</Accordion>
|
|
269
303
|
<Accordion title="A message keeps retrying and then disappears">
|
|
270
304
|
|
|
271
|
-
When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`) — it is not lost.
|
|
305
|
+
When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`) — it is not lost. List it from a Flow with `fx.deadLetters(signal)`, or open Console → Flows, inspect the typed failure, fix the consumer, then replay.
|
|
306
|
+
|
|
307
|
+
</Accordion>
|
|
308
|
+
<Accordion title="fx.deadLetters throws OKE1001">
|
|
309
|
+
|
|
310
|
+
The flow read a signal it did not declare. The compiler infers `reads: ["signal:<name>"]` from `fx.deadLetters(signal)`. A different handle needs its own read.
|
|
311
|
+
|
|
312
|
+
</Accordion>
|
|
313
|
+
<Accordion title="on(liveSignal, flow) never replays history">
|
|
314
|
+
|
|
315
|
+
`on(signal, flow)` listens for `once` and `broadcast`. `live` replay is `bus.live()` on the Signal
|
|
316
|
+
bus — a Flow trigger does not replay retained payloads.
|
|
272
317
|
|
|
273
318
|
</Accordion>
|
|
274
319
|
<Accordion title="once vs broadcast vs live — how do I choose?">
|
|
@@ -291,6 +336,7 @@ At-least-once: crash-after-claim reclaims when the lease expires, and a handler
|
|
|
291
336
|
## Learn more
|
|
292
337
|
|
|
293
338
|
- [Flow](/docs/elements/flow) — `on(trigger, flow)` and `fx.emit`
|
|
339
|
+
- [fx](/docs/reference/fx) — `fx.emit` and `fx.deadLetters`
|
|
294
340
|
- [Clock](/docs/elements/clock) — scheduled and delayed work
|
|
295
341
|
|
|
296
342
|
## Next
|
|
@@ -777,7 +777,7 @@ images: {
|
|
|
777
777
|
|
|
778
778
|
### Images — `image` / `putImage`
|
|
779
779
|
|
|
780
|
-
For photos, use Bun's built-in `Bun.Image` pipeline on the same handle (requires Bun `>=1.
|
|
780
|
+
For photos, use Bun's built-in `Bun.Image` pipeline on the same handle (requires Bun `>=1.4.0`).
|
|
781
781
|
`putImage` is one write that fans into several keys:
|
|
782
782
|
|
|
783
783
|
<StoreFilesVariants />
|
|
@@ -6,7 +6,7 @@ icon: Download
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
This page gets you from zero to a running app on Bun. The engine targets
|
|
9
|
-
**Bun ≥ 1.
|
|
9
|
+
**Bun ≥ 1.4.0** — prefer it for install, scaffold, and `oke dev`.
|
|
10
10
|
|
|
11
11
|
<Callout title="The one rule">
|
|
12
12
|
The npm package is `okengine`; the CLI binary is `oke`. Use `bun add` in a project or `bun install
|
|
@@ -20,7 +20,7 @@ This page gets you from zero to a running app on Bun. The engine targets
|
|
|
20
20
|
<Step>
|
|
21
21
|
### Prerequisites
|
|
22
22
|
|
|
23
|
-
- [Bun](https://bun.sh) ≥ 1.
|
|
23
|
+
- [Bun](https://bun.sh) ≥ 1.4.0 (`bun --version`)
|
|
24
24
|
- [Docker](https://docs.docker.com/get-docker/) with a running daemon (`docker info`)
|
|
25
25
|
- A terminal and a code editor
|
|
26
26
|
|
|
@@ -33,9 +33,10 @@ See [Store](/docs/elements/store) for the query-builder surface.
|
|
|
33
33
|
|
|
34
34
|
## Signals
|
|
35
35
|
|
|
36
|
-
| Signature | Records
|
|
37
|
-
| ------------------------------------- |
|
|
38
|
-
| `fx.emit(signal, payload?, { key? })` | `emit`
|
|
36
|
+
| Signature | Records | Notes |
|
|
37
|
+
| ------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
38
|
+
| `fx.emit(signal, payload?, { key? })` | `emit` | Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; stamps producer run id as `parentRunId` for trace chains; throws **OKE1042** (orphan) or **OKE1043** (schema) |
|
|
39
|
+
| `fx.deadLetters(signal)` | `read` `signal:<name>` | Dead-lettered messages for that signal. Payload typed from `SignalDecl<T>`. Page with `fx.json.withQuery`. Cross-signal throws **OKE1001**. |
|
|
39
40
|
|
|
40
41
|
## Runs (observability read)
|
|
41
42
|
|
|
@@ -145,12 +146,12 @@ contact a provider. Channel bodies use `{{field}}` catalogs — not ICU (see [Ch
|
|
|
145
146
|
|
|
146
147
|
## AI
|
|
147
148
|
|
|
148
|
-
| Signature | Records | Returns
|
|
149
|
-
| ----------------------------------------------------- | ------------------------- |
|
|
150
|
-
| `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out`
|
|
151
|
-
| `fx.run(agent, input?)` | `ask` | Agent result
|
|
152
|
-
| `fx.stream(model, { prompt?, data? })`
|
|
153
|
-
| `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed
|
|
149
|
+
| Signature | Records | Returns |
|
|
150
|
+
| ----------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
151
|
+
| `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
|
|
152
|
+
| `fx.run(agent, input?)` | `ask` | Agent result |
|
|
153
|
+
| `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
|
|
154
|
+
| `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
|
|
154
155
|
|
|
155
156
|
AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them. `tools` are Flow refs — each model tool call goes through `fx.call` (same capability and Runs path).
|
|
156
157
|
|
|
@@ -169,12 +170,15 @@ AI calls are nondeterministic: journaling is forced on and auto-cache disabled a
|
|
|
169
170
|
|
|
170
171
|
## Clock
|
|
171
172
|
|
|
172
|
-
| Signature | Notes
|
|
173
|
-
| --------------------------------- |
|
|
174
|
-
| `fx.clock.now()` | Epoch-ms, injectable — the only legal "now"
|
|
175
|
-
| `fx.clock.
|
|
173
|
+
| Signature | Notes |
|
|
174
|
+
| --------------------------------- | ------------------------------------------------------------------- |
|
|
175
|
+
| `fx.clock.now()` | Epoch-ms, injectable — the only legal "now" |
|
|
176
|
+
| `fx.clock.ago(duration)` | Instant before now (`"30d"` → now − 30 days) |
|
|
177
|
+
| `fx.clock.fromNow(duration)` | Instant after now (`"14d"` → now + 14 days) |
|
|
178
|
+
| `fx.clock.duration(duration)` | Span in ms — offset a stored instant (`createdAt + duration("7d")`) |
|
|
179
|
+
| `fx.clock.sleep(label, duration)` | Durable sleep in `durable` flows; immediate otherwise |
|
|
176
180
|
|
|
177
|
-
Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`.
|
|
181
|
+
Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400_000 ms, not a calendar day. Unknown strings parse as `0`.
|
|
178
182
|
|
|
179
183
|
## Cache
|
|
180
184
|
|
|
@@ -192,15 +196,16 @@ keys. Use `cache: false` to opt out, or `cache: "30s"` for a TTL.
|
|
|
192
196
|
|
|
193
197
|
## Responses
|
|
194
198
|
|
|
195
|
-
| Helper | Status | Body
|
|
196
|
-
| ----------------------------------------- | ------ |
|
|
197
|
-
| `fx.json.ok(value, { meta? })` | 200 | `{ data, meta?, error: null }`
|
|
198
|
-
| `fx.json.create(value)` | 201 | `{ data, error: null }`
|
|
199
|
-
| `fx.json.empty()` | 204 | no body
|
|
200
|
-
| `fx.json.with(page)` / `with(data, meta)` | 200 | `{ data, meta, error: null }` — already-built pager
|
|
201
|
-
| `fx.json.withQuery(rows, input, spec?)` | 200 | In-memory list page — zero-config `q` / auto-eq / PostgREST
|
|
199
|
+
| Helper | Status | Body |
|
|
200
|
+
| ----------------------------------------- | ------ | -------------------------------------------------------------- |
|
|
201
|
+
| `fx.json.ok(value, { meta? })` | 200 | `{ data, meta?, error: null }` |
|
|
202
|
+
| `fx.json.create(value)` | 201 | `{ data, error: null }` |
|
|
203
|
+
| `fx.json.empty()` | 204 | no body |
|
|
204
|
+
| `fx.json.with(page)` / `with(data, meta)` | 200 | `{ data, meta, error: null }` — already-built pager |
|
|
205
|
+
| `fx.json.withQuery(rows, input, spec?)` | 200 | In-memory list page — zero-config `q` / auto-eq / PostgREST |
|
|
206
|
+
| `fx.json.stream(chunks)` | 200 | `text/event-stream` — JSON `data:` frames, then `data: [DONE]` |
|
|
202
207
|
|
|
203
|
-
Returning a plain value instead answers 200 with `{ data: value, error: null }` — the helpers exist for status and `meta` control.
|
|
208
|
+
Returning a plain value instead answers 200 with `{ data: value, error: null }` — the helpers exist for status and `meta` control. Pass `fx.stream(...)` into `fx.json.stream` to reach the HTTP client token-by-token.
|
|
204
209
|
|
|
205
210
|
## Logging, i18n, ids
|
|
206
211
|
|