Reference

fx

The complete fx surface — every call a Flow can make, its signature, and the effect it records.

fx is the second argument of every do — the single door to the world. This page is the whole surface; each entry notes the effect it records, which feeds the Manifest, caching, and capability checks.

The one rule

All world access goes through fx. No node: I/O, no raw fetch for side effects (use fx.fetch instead), no Date.now() — clocks, stores, channels, vault, AI, and outbound HTTP only through this object.

Smallest Example

Use fx inside do

src/flows/notes/create.ts
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      await fx.store(db).insert(notes).values({ id, title });
      return fx.json.create({ id, title });
    },
  }),
);

Type extracted helpers as Fx

import type { Fx } from "okengine";

async function loadNote(id: string, fx: Fx) {
  return fx.store(db).findById(notes, id);
}

A narrower structural type will not match store() overloads. See Flow.

What the effects ledger powers

Derived behaviorFrom
Cache invalidationInferred / ledgered reads · writes
Live queries / CDCStore writes observed as row events
Least-privilege tokensEffect matrices on Flows
Deterministic testsInjectable clock · store · channel
Runs observabilityWide events per invocation (fx.runs)

Stores

SignatureRecordsReturns
fx.store(sqlDecl).select().from(t)…readinferred rows (where · orderBy · limit · offset chainable)
fx.store(sqlDecl).insert(t).values(v)writePromise<void>
fx.store(sqlDecl).update(t).set(v).where(…)writePromise<void>
fx.store(sqlDecl).delete(t).where(…)writePromise<void>
fx.store(sqlDecl).findById(t, id)readrow | undefined
fx.store(sqlDecl).search(table, { query, fuse?, rerank?, … })read (+ ask if rerank){ data, meta } hybrid BM25 ± LSH — see Search
fx.store(kv).get / set(key, value, ttl?) / delete / list(prefix?)read / writeper op
fx.store(files).put / get / delete / list(prefix?)read / writeper op
fx.store(files).image(key|bytes).…read / writeBun.Image chain; terminals gate (see Store)
fx.store(files).putImage(key, data, opts?)writeoriginal + variants (+ optional LQIP)
fx.store(index).upsert / search(vector, topK?) / deleteread / writeper op

See Store for the query-builder surface.

Signals

SignatureRecordsNotes
fx.emit(signal, payload?, { key? })emitPass a SignalDecl<T> handle to type-check payload; a string name stays unknown (runtime schema still applies). Commits the signal outbox when the call resolves; optional key serializes once per key; stamps producer run id as parentRunId for trace chains; throws OKE1240 (orphan) or OKE1250 (schema)
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.
fx.live(signal, { match? })read signal:<name>Live tape as SSE. Returns JsonStreamResult (object chunks, id: on the wire). Cross-signal throws OKE1001. Do not wrap with fx.json.stream.

Runs (observability read)

Declare effects: { reads: ["runs"] }. Powers native SLO checkers (Clock + Channel) without a parallel metrics API.

SignatureRecordsReturns
fx.runs.query(sql)read runsSQL rows (FROM runs on files/memory). Unrestricted Flow SQL — not the Console sandbox.
fx.runs.all()read runsAll wide events
fx.runs.window(flow, windowMs?)read runsRolling P50/P95/P99 + error rate (default 5m)
fx.runs.checkSlo(flow, slo, windowMs?)read runsAvailability / latency breaches
const sloCheckClock = clock.every("ops.slo-check", "5m");

on(
  sloCheckClock,
  flow("ops.slo-check", {
    effects: { reads: ["runs"], sends: ["slo-alert"] },
    do: async (_, fx) => {
      const breaches = await fx.runs.checkSlo(
        "checkout.create",
        { availability: "99.9%", latency: { p95: "200ms" } },
        5 * 60_000,
      );
      if (breaches.length === 0) return;
      await fx.send(sloAlert, {
        to: "oncall@example.com",
        data: { flow: "checkout.create", count: breaches.length },
      });
    },
  }),
);

Flows

SignatureRecordsReturns / notes
fx.call(flow, input?)callThe callee's out — runs through the same pipeline
fx.step(name, fn)Durable step: replays from the journal, never re-runs
fx.all([...thunks])Parallel; first rejection aborts siblings
fx.race([...thunks])First settle wins; losers aborted
fx.retry(fn, opts?)Exponential backoff + jitter (plain Promise)
fx.using(acq, rel, use)release runs once on settle or ambient abort
fx.signalAmbient AbortSignal for the current branch
fx.fail(code, data, opts?)Typed failure value (opts.message overrides)
fx.auth.createApiKey({ name, scopes, expiresIn?, ipAllowlist?, rateLimit? })write auth:api-keysSecret once. Creator is live userId / scopes. Session only. ipAllowlist is IPs or hostnames.
fx.auth.listApiKeys()read auth:api-keysKeys this session minted
fx.auth.revokeApiKey(id)write auth:api-keysOwner only
fx.auth.rotateApiKey(id)write auth:api-keysNew secret once. Owner only
fx.auth.updateApiKey(id, …)write auth:api-keysName / scopes / expiry / allowlist / rate. Re-attenuates
fx.auth.listTenants()read auth:tenantsMemberships for the live session. Session only
fx.auth.switchTenant(id)write auth:tenantsNew access+refresh, new family, tid on both. Never Set-Cookie. Session only
fx.auth.createTenant({ name, slug?, id? })write auth:tenantsCreator becomes a member. Session only
fx.auth.upsertTenantRole({ tenantId, roleName, scopes })write auth:tenantsApplication scopes only — console:* is unknown_scope

fx.call starts the callee with an empty fx.auth (fail-closed for authorization) and propagates fx.tenant.id. For audit/attribution only, read fx.principal — it propagates the originating identity without copying into fx.auth. Gates never consult fx.principal.

Concurrency and retry

Pass thunks to all / race — not already-started Promises — so each branch gets an abort scope before work begins.

const [user, stock] = await fx.all([
  () => fx.store(db).findById(users, input.userId),
  () => fx.store(db).findById(inventory, input.sku),
]);

const charge = await fx.step("charge", () =>
  fx.retry(() => fx.call(stripeCharge, { amount: input.total }), {
    retries: 3,
    delay: "100ms",
    backoff: 2,
    jitter: true,
  }),
);
fx.retry optionDefaultMeaning
retries0Extra attempts after the first
delay50Initial backoff — ms number or "100ms"
backoff2Multiplier after each retry
jittertrueFull jitter on the delay (thundering-herd)
whenthrownPredicate; skips AbortError and sleep park

Cooperative cancel

Losing branches see fx.signal abort. Drivers that do not yet honor the signal may still finish in the background — check fx.signal.aborted in long user work, and prefer fx.all over bare Promise.all.

fx.using(acquire, release, use) scopes a process-local resource to one attempt: release runs exactly once when use settles or when the ambient signal aborts (a sibling fx.race winner, a failing fx.all sibling). It is not journaled — do not hold handles across durable park/resume.

const rows = await fx.using(
  () => pool.acquire(),
  (conn) => conn.release(),
  (conn) => conn.query("select …"),
);

Consequence: put fx.retry inside fx.step on durable flows so a completed charge never re-runs on resume. Coarse whole-body retry is flow(name, { retry: { … } }) on the same journal session.

Channel

SignatureRecordsNotes
fx.send(template, { to?, data?, via?, … })sendvia orders fallback; locale / profileLocale / acceptLanguage feed the locale chain

Omit locale opts and the send uses fx.locale. Dry runs record would have fired and never contact a provider. Channel bodies use {{field}} catalogs — not ICU (see Channel).

Outbound HTTP

SignatureRecordsNotes
fx.fetch(url, init?)fetch on the URL hostnameAlways stamps EffectEntry.external with { host, kind: "third-party" }

Declare hosts in effects.fetches (e.g. ["api.stripe.com"]). Prefer fx.step; use fx.retry only when the remote API is safe to repeat. Dry runs never hit the network.

Use this for third-party REST outside Channel / AI / Store — not as a substitute for those elements.

AI

SignatureRecordsReturns
fx.ask(prompt, input?, { via?, tools?, maxSteps? })ask (+ call per tool)Object validated against the prompt's out
fx.run(agent, input?)askAgent result
fx.stream(model, { prompt?, data?, via? })askAsyncIterable<string> — real driver stream; cancels via ambient fx.signal (HTTP disconnect included)
fx.search(embed, query, { topK? })readMatches from the index/embed

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).

Driver-reported EffectEntry.external marks cloud providers as third-party and self-hosted / provider: "local" as infrastructure (Console waterfall dashed egress + host tooltip).

Vault

SignatureRecordsReturns / notes
fx.vault.get(contract)secretPromise<Redacted<string>> — prints/logs as a placeholder; .reveal() at the credential boundary
fx.vault.set(path, value, opts?)secret{ path, version } — needs a bound Vault backend
fx.vault.rotate(path, value)secret{ path, version } — new version with a fresh data key
fx.vault.delete(path)secretboolean — crypto-shreds the path
fx.vault.list(prefix?)Secret paths, never values
fx.vault.status(){ sealed, initialized, backend }

get reads through the boot resolution chain. Everything else needs the encrypted-at-rest backend (drivers.vault = "vault") and throws without it.

Clock

SignatureNotes
fx.clock.now()Epoch-ms, injectable — the only legal "now"
fx.clock.ago(duration)Instant before now ("30d" → now − 30 days)
fx.clock.fromNow(duration)Instant after now ("14d" → now + 14 days)
fx.clock.duration(duration)Span in ms — offset a stored instant (createdAt + duration("7d"))
fx.clock.sleep(label, duration)Durable sleep in durable flows; immediate otherwise

Durations: "200ms" · "30s" · "2m" · "1h" · "7d". A "d" is 86_400_000 ms, not a calendar day. Unknown strings parse as 0.

Cache

Read-only flows cache automatically from inferred or ledgered reads — no fx.cache call and no cache: default on the flow. Writes invalidate those keys. Use cache: false to opt out, or cache: "30s" for a TTL.

fx.cache is the manual (tier-3) surface:

SignatureNotes
fx.cache.get(key)Value or undefined
fx.cache.set(key, value, ttl?)Optional TTL string
fx.cache.getOrSet(key, ttl, produce)Read-through; writes invalidate by effect

Responses

HelperStatusBody
fx.json.ok(value, { meta? })200{ data, meta?, error: null }
fx.json.create(value)201{ data, error: null }
fx.json.empty()204no body
fx.json.with(page) / with(data, meta)200{ data, meta, error: null } — already-built pager
fx.json.withQuery(rows, input, spec?)200In-memory list page — zero-config q / auto-eq / PostgREST
fx.json.stream(chunks)200text/event-stream — JSON data: frames, then data: [DONE]
fx.live(signal)200Same SSE carrier for a live signal (payload frames + id:)

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.

Logging, i18n, ids

SignatureNotes
fx.log.debug/info/warn/error(msg, data?)Redacting — secrets print as ***
fx.t(key, values?)ICU MessageFormat — active locale → i18n.default → key
fx.localeActive locale (Accept-Language matched to i18n.locales)
fx.id()OKID — 21-char native id from okengine/okid

Catalogs, ICU syntax, and Register augmentation live on i18n. Id options: OKID. Localized fx.fail / OkeError catalogs: Errors.

Principals

PropertyShape
fx.auth{ userId, scopes, verified?, apiKeyId? } plus key and tenant methods (session only)
fx.operator{ id: string | null } — Console plane
fx.principalRead-only origin: userId, operatorId, scopes, verified?, plane?
fx.tenant{ id: string | null } — active tenant (propagates on fx.call)

Consequence: use fx.auth / gates for authorization; use fx.principal only when a callee must log who started the call chain. A key Bearer sets userId to the issuer and apiKeyId to the key — see Gate.

Not on fx

No fx.metric

Investigated and declined. fx.runs already provides per-invocation observability as wide events. Native alerting is fx.runs + Clock + Channel — not a second counter/gauge API. Optional OTLP export for existing Grafana/Datadog stacks is additive and never required.

Troubleshooting

Learn more

  • Flow — why fx is the only door
  • Channelfx.send, consent, locale chain, {{field}} catalogs
  • i18nfx.t, catalogs, locale matching
  • Errors — what fx.fail produces
  • Configuration — drivers and the i18n block
  • OKIDfx.id() options

Next

On this page