Overview
One Flow species for HTTP, Signal, Clock, CDC, and call-only work — contracts, fx, and options in one place.
Flow is the element for behavior. An HTTP endpoint, a Signal worker, a named Clock tick, a SQL change handler, and a multi-step checkout are the same shape: on(trigger, flow). Only the trigger changes.
For developers writing backend work on okengine — put the invoke contract on the exposure
(http.* / call / mcp.tool), keep do on fx.
The one rule
All world access goes through fx. A direct fetch, Date.now(), or node: import inside do
is a defect. Effects are inferred from what the Flow touches through fx — that inference powers
the Manifest, Console, cache, and durability.
on(Trigger) → Effects
one species- 01
Triggerany trigger → one flowhttpclocksignalcdcmcpon(http.post("/orders"), createOrder)How work starts. Only this piece changes between an endpoint, a job, a consumer, and a row hook.
- 02
Contractsexposure invoke contractinouterrorsexposurehttp.post({ in: z.object({ sku: z.string() }) })Invoke contracts live on the exposure (HTTP, call, MCP). Manifest projects in/out/errors onto flows.*.
- 03
do + fxno side-channel I/Odofxsingle doorawait fx.store(db).insert(orders).values(input)The body. Every read, write, emit, secret, and model call goes through fx — side-channel I/O is a defect.
- 04
Effectsauto-derived manifestinferredmanifest diffwrites: ["sql:orders"] · emits: ["orderPlaced"]Recorded from fx touches. Cache keys, capability tokens, live queries, and Manifest Diff fall out — no hand annotations.
Smallest Example
Define a Flow
import { on, flow, http } from "okengine";
export const health = on(
http.get().public(),
flow({
do: () => ({ ok: true }),
}),
);Call it
curl -X GET http://localhost:6530/health -H "accept: application/json"Response:
{
"data": { "ok": true },
"error": null
}Omit path and name
Tree default: http.get() + flow({ do }) — no path or name strings. Pass
either only for
control.
Call-only Flows
Use call("payments.charge", { in, out, do, … }) for internal callees — same species as
flow, with the invoke contract on the bag. Nothing outside your code can start it unless you
also bind a trigger. Other Flows invoke it with fx.call(flowRef, input).
Progressive Patterns
Same on + flow + do from a ping to a typed failure to a private callee:
Return a value. HTTP wraps it as { data, error: null }:
import { on, flow, http } from "okengine";
export const ping = on(
http.get().public(),
flow({
do: () => ({ status: "ok" }),
}),
);Trigger Reference
flow, do, and fx never change. Bind a different trigger:
Five triggers, one species
on(trigger, flow)one Flow
orders.createBinding
on(http.post("/orders"), createOrder)HTTP requests validate JSON body, query params, and headers directly into in.
Contracts, do, effects — identical shape. Only the trigger changed.
| Trigger | Bind | Starts when | do input |
|---|---|---|---|
| HTTP | on(http.get(), flow) | A request | Merged path / query / body |
| Signal | on(signalHandle, flow) | fx.emit | Payload (schema) |
| Clock | on(clockDecl, flow) | Scheduler tick | none (_) |
| CDC | on(db.table(t).changed(), flow) | Committed SQL write | { before, after, table, action, id } |
| Call-only | call("name", { in, out, do, … }) | fx.call | Callee in |
| MCP | on(mcp.tool("x", { in, out }).gate(…), flow) | MCP tools/call | Tool args |
signal.live is an HTTP SSE tape — bind it with http.live, not as a worker.
The Capabilities of Flow
HTTP
REST verbs, RFC 10008 QUERY, CRUD mounts, and live SSE.
Routing
File-tree stamps for HTTP paths, Flow names, and client units.
Consumers
Signal workers, named Clock jobs, and SQL CDC — one species.
Durable Workflows
fx.step replay, LIFO undo, durable sleep, and crash resume.
Options Reference
Second argument to flow(name, options) — or the only argument to a nameless flow({ do }).
Invoke contracts (in / out / errors / breaking) belong on the exposure — see
Contracts below.
| Option | Type | Default | Meaning |
|---|---|---|---|
do | (input, fx) => output | FlowFailure | (required) | Handler. Missing do throws flow() expected an options bag with a do handler |
durable | boolean | false | Journal fx.step / sleep / gated fx calls |
retry | FxRetryOptions | omitted | Whole-do retry on throw (same journal when durable) |
cache | boolean | string | omitted (auto) | Read-only Flows cache automatically; false opts out; "30s" adds TTL |
compensate | (ctx, fx) => unknown | omitted | After LIFO { undo }, before the run commits failed |
plane | "user" | "operator" | "user" | Operator bypasses RLS; user must not fx.call operator |
effects | Effects | inferred | Capability token — write this only when inference cannot see the body |
slo | { availability?, latency? } | omitted | Manifest metadata (Console / docs) |
tenantScoped | boolean | true when tenancy is on | false skips tenant-role scope union |
Consequence: durable: true disables automatic read-cache for that Flow.
Contracts
Detailed section
Invoke contracts live on the exposure — http.post({ in, out, errors }), call("name", { in, out, do }), or mcp.tool("x", { in, out }). The Manifest still shows flat
flows.*.{in,out,errors,breaking} as a projection from that exposure. in runs before do;
out runs after a successful return. fx.fail skips out. Signal / Channel schema is a
separate emit contract (validated at fx.emit / fx.send).
Any library with ~standard (Standard Schema V1) works. Zod is the usual choice:
import { on, flow, http } from "okengine";
import { z } from "zod";
on(
http.post({
in: z.object({ sku: z.string(), qty: z.number().int().min(1) }),
out: z.object({ id: z.string() }),
}),
flow({
do: async (input, fx) => ({ id: fx.id() }),
}),
);Valibot (v.object) and ArkType (type({…})) bind the same way. Shared DTOs belong in
shapes.ts next to the unit — that filename is never a route.
store.resource(db, table, { in, out }) requires in (create body) and out (item
shape). List / get / update / remove Flows are built for you — contracts are stamped
from the resource factory. Handwritten invoke contracts go on http.* / call /
mcp.tool — see HTTP · Resources.
Failed in (or out) is ValidationError with error.data.issues — each issue has message
and path. HTTP status is 422. The handler never ran.
Prefer nameless flow({ do }) on HTTP tree files — src/flows/notes/[id]/get.ts +
export const get stamps notes.get. Signal / Clock workers pass flow("name", { do })
(OKE1072; Clock inline is Clock · Inline or named export).
The fx door
Detailed section
If you only need store / emit, jump to the table. fx is the only I/O surface inside do. The
compiler records what you touch as effects on the Manifest.
| Call | Records | Use |
|---|---|---|
fx.store(db) | reads / writes sql:… | SQL (and other Store facets) |
fx.emit(signal, payload) | emits | Signal outbox |
fx.send(template, opts) | sends | Channel template |
fx.ask(prompt, opts) | asks | AI prompt |
fx.vault.get(secret) | secrets | Declared secret (never a raw value in source) |
fx.call(flow, input?) | calls | Another Flow — waits for return |
fx.id() | — | OKID — 21-char native id from okengine/okid |
fx.clock.now() | — | Deterministic time |
fx.fail(code, data) | — | Typed failure value |
fx.step(name, fn) | journal | Durable checkpoint |
Date.now(), new Date(), setTimeout, global fetch, and node:fs skip the ledger.
Tests cannot time-travel; durable replay cannot skip the work; cache cannot see the read.
Fix: fx.clock.now(), fx.store, fx.send, or wrap a provider in fx.step.
fx.call starts the callee with an empty fx.auth (fail-closed). fx.tenant.id propagates.
For audit only, read fx.principal — gates never consult it. See fx.
Explicit effects that drift from the body throw at runtime (Flow "{flow}" writes "{resource}" without declaring it.). Most apps never write effects — inference covers them. OKE1020 is
deploy-shaped boot with neither inference nor a block.
Call-only
Detailed section
Prefer call("name", { in, out, do, … }). internal exists so call-only is a trigger
value — on(internal, flow) — when you need all kinds addressable the same way.
import { on, flow, http } from "okengine";
import { z } from "zod";
import { chargeCard } from "@/flows/payments/charge";
export const checkout = on(
http.post({ in: z.object({ sku: z.string() }) }),
flow({
do: async ({ sku }, fx) => {
const { chargeId } = await fx.call(chargeCard, { amount: 50 });
return { sku, chargeId };
},
}),
);Do not fx.call a Flow that parks on fx.clock.sleep — the caller receives
undefined and continues; the child wakes later as its own run. Sleep on the root
durable Flow, or split with fx.emit. See Workflows.
Cache, retry, and plane
Read-only Flows (Store reads, no writes, no asks, not durable) cache automatically.
No cache: option required. Mutations and durable: true stay uncached:
flow("catalog.get", {
cache: "30s",
do: async ({ id }, fx) => {
const [row] = await fx.store(db).select().from(products).where(eq(products.id, id));
return row;
},
});cache: false opts out. A duration string adds TTL on top of write invalidation.
Durable-only. Runs after reverse { undo }, never on success, retry attempts, or sleep
park. Context: { input, error, completedSteps } — forward step names only. Full
physics: Workflows · Compensation.
breaking: true on the exposure bag lets oke doctor --diff accept that Flow's contract
break. It does not cover a different Flow.
tenantScoped: false skips tenant-role scope union even when fx.tenant.id is set.
Default is true once gate.auth.tenant is on.
flowDef.hook(stage, fn) registers a per-Flow hook (onRequest · onParse · onAuth ·
beforeHandle · afterHandle · onError · onResponse). flowDef.plug(plugin) scopes a plugin
to that Flow — see Plugins.
Troubleshooting
flow() requires { do }. flow("name") with no options, or a bag without do,
throws at declaration — before on().
First argument must be an HTTP trigger, Signal handle, Clock handle, db.table(…).changed(),
internal, or mcp.tool(…). A bare interval string is not a trigger — wrap it in
clock.every("name", "1h").
Second argument must be the object flow() returned. Passing a plain function or
forgetting flow({ do }) fails here. Resource mounts take no second argument —
see HTTP · Resources.
Side channels skip effect tracking and durable replay.
Fix: fx.clock.now(), fx.store, fx.send, or fx.step around the provider.
Uncaught exceptions are defects. Declare the code in errors on the exposure and return fx.fail("OutOfStock", payload) from do.
http.get("/users/:id") merges {id}. A schema that expects userId fails before do. Align
path keys with in object keys. See HTTP · Request
Parsing.
Custom domain codes map to 400. A bare 404 Not Found means the router found no method +
path. Use fx.fail for domain misses; fix the route for missing bindings.
Auto-cache needs Store reads, no writes, no asks, and durable off. Empty effect sets stay
uncached. Opt in with a duration (cache: "30s") only after a real read is inferred — or pass
cache: false to disable.
User-plane Flows cannot fx.call operator Flows. Keep operator work on plane: "operator" and
invoke it from Console, or split a user-safe callee.
Cause: Flow "{flow}" has no declared effects and no Manifest to derive them from. Extract
failures append Manifest extract failed — …. Boot with oke dev / oke build; install
oxc-parser if extract cannot load. On Windows use bun run dev / bunx oke dev.
Learn more
- The Architecture —
on, trigger,flow,do,fx - HTTP — verbs, envelopes, resources, live SSE
- Routing — file-tree stamps, barrels, OKE1030 · OKE1040–1045
- Consumers — Signal / Clock / CDC
- Workflows —
durable: true+fx.step - fx — every method inside
do - Gate —
.gate(...)/.public()on the trigger - Errors — OKE1001–1009 · OKE1020 · ValidationError · denials
- MCP —
mcp.toolexposure